Redis2.8源码之客户端工具

173 阅读27分钟

客户端工具源码

本节将学习Redis2.8源码中的redis-cli.c文件,并对其中代码做好详细注解。

1.1 从main()开始

直接从源文件中的main()函数开始,内容如下:

int main(int argc, char **argv) {
    int firstarg;
​
    // 初始化配置,部分核心值可根据命令选项设置
    config.hostip = sdsnew("127.0.0.1");
    config.hostport = 6379;  // Redis服务端口
    config.hostsocket = NULL;
    config.repeat = 1;   
    config.interval = 0;
    config.dbnum = 0;
    config.interactive = 0;
    config.shutdown = 0;
    config.monitor_mode = 0;  
    config.pubsub_mode = 0;
    config.latency_mode = 0;
    config.latency_history = 0;
    config.cluster_mode = 0;
    config.slave_mode = 0;
    config.getrdb_mode = 0;
    config.rdb_filename = NULL;
    config.pipe_mode = 0;
    config.pipe_timeout = REDIS_DEFAULT_PIPE_TIMEOUT;
    config.bigkeys = 0;
    config.stdinarg = 0;
    config.auth = NULL;
    config.eval = NULL;
    // 判断当前的stdout是否是为终端
    if (!isatty(fileno(stdout)) && (getenv("FAKETTY") == NULL))
        config.output = OUTPUT_RAW;
    else
        config.output = OUTPUT_STANDARD;
    config.mb_delim = sdsnew("\n");
    // 1. 初始化帮助信息
    cliInitHelp(); 
​
    // 2. 解析选项
    firstarg = parseOptions(argc,argv);
    argc -= firstarg; 
    argv += firstarg;
​
    // 3. 延迟模式
    if (config.latency_mode) {
        if (cliConnect(0) == REDIS_ERR) exit(1);
        latencyMode();
    }
​
    // 4. 从节点模式
    if (config.slave_mode) {
        if (cliConnect(0) == REDIS_ERR) exit(1);
        slaveMode();
    }
​
    // 5. 获取rdb模式
    if (config.getrdb_mode) {
        if (cliConnect(0) == REDIS_ERR) exit(1);
        getRDB();
    }
​
    // 6. 管道模式
    if (config.pipe_mode) {
        if (cliConnect(0) == REDIS_ERR) exit(1);
        pipeMode();
    }
​
    // 7. 查找大key模式
    if (config.bigkeys) {
        if (cliConnect(0) == REDIS_ERR) exit(1);
        findBigKeys();
    }
​
   // 8. 查看状态
    if (config.stat_mode) {
        if (cliConnect(0) == REDIS_ERR) exit(1);
        if (config.interval == 0) config.interval = 1000000;
        statMode();
    }
​
    // 9. 交互模式
    if (argc == 0 && !config.eval) {
        /* Note that in repl mode we don't abort on connection error.
         * A new attempt will be performed for every command send. */
        cliConnect(0);
        repl();
    }
​
    // 10. 将参数当作命令执行
    if (cliConnect(0) != REDIS_OK) exit(1);
    if (config.eval) {
        return evalMode(argc,argv);
    } else {
        return noninteractive(argc,convertToSds(argc,argv));
    }
}

先看第1处代码,主要是调用cliInitHelp()函数初始化帮助信息。该函数的源码如下:

// redis-cli.c
// ...
​
static void cliInitHelp() {
    // 计算Redis中支持命令总数
    int commandslen = sizeof(commandHelp)/sizeof(struct commandHelp);
    // 计算Redis中支持的命令组数
    int groupslen = sizeof(commandGroups)/sizeof(char*);
    int i, len, pos = 0;
    helpEntry tmp;
​
    helpEntriesLen = len = commandslen+groupslen;
    helpEntries = malloc(sizeof(helpEntry)*len); 
​
    for (i = 0; i < groupslen; i++) {
        tmp.argc = 1;
        tmp.argv = malloc(sizeof(sds));
        tmp.argv[0] = sdscatprintf(sdsempty(),"@%s",commandGroups[i]);
        tmp.full = tmp.argv[0]; // char *,记录了命令的组名
        tmp.type = CLI_HELP_GROUP;
        tmp.org = NULL;
        helpEntries[pos++] = tmp; // 结构体赋值,之后对tmp的修改不会影响到helpEntries[pos]位置
    }
​
    for (i = 0; i < commandslen; i++) {
        tmp.argv = sdssplitargs(commandHelp[i].name,&tmp.argc);
        tmp.full = sdsnew(commandHelp[i].name); // 命令名称-sds
        tmp.type = CLI_HELP_COMMAND;
        tmp.org = &commandHelp[i];              // 命令信息的结构体地址
        helpEntries[pos++] = tmp;
    }
}
​
// ...

关于commandHelp结构体数组的定义位于help.h中,它保存了Redis2.8中对命令的说明信息,部分内容如下:

// help.c
// ...static char *commandGroups[] = {   // 命令组,每个命令都属于一个组,Redis对所有命令进行了分组
    "generic",
    "string",
    "list",
    "set",
    "sorted_set",
    "hash",
    "pubsub",
    "transactions",
    "connection",
    "server",
    "scripting"
};
​
struct commandHelp {
  char *name;      // 命令名称
  char *params;    // 命令参数
  char *summary;   // 命令说明
  int group;       // 命令所属组
  char *since;     // 自哪个版本开始
} commandHelp[] = {
    { "APPEND",
    "key value",
    "Append a value to a key",
    1,
    "2.0.0" },
    
    // 忽略后面内容
    // ...
    
}
​
// ...

此外,cliInitHelp()函数中还涉及到的helpEntry结构体,它的定义如下:

// redis-cli.c
// ...typedef struct {
    int type;
    int argc;
    sds *argv;
    sds full;
​
    /* Only used for help on commands */
    struct commandHelp *org;
} helpEntry;
​
// ...

cliInitHelp()函数比较简单,它主要是更新helpEntries这个结构体变量,它是一个静态变量。该值首先调用malloc()分配足够空间,然后保存Redis中定义的命令组信息以及Redis命令的帮助信息。

接下来是main()函数的第2处代码。很明显,parseOptions()函数用于解析redis-cli命令的选项,参数argvargc分别用于保存命令外部传入参数以及参数个数。例如下面的命令输入:

[root@center redis2.8]# ./redis-cli -h 6379 -h 192.168.126.140

此时有argv = ["./redis-cli", "-h", "6379", "-h", "192.168.126.140"],argc=5。继续看该函数地实现源码,内容如下:

// ...
​
static int parseOptions(int argc, char **argv) {
    int i;
​
    for (i = 1; i < argc; i++) {
        int lastarg = i==argc-1;  // 最后一个参数位置的索引值
​
        if (!strcmp(argv[i],"-h") && !lastarg) {  // 如果输入-h参数
            sdsfree(config.hostip);               // 释放原分配内容,然后用sdsnew新分配内存保存
            config.hostip = sdsnew(argv[++i]);    // 下一个参数值,即argv[i+1],这里用的++i
        } else if (!strcmp(argv[i],"-h") && lastarg) {
            usage();                              // 如果-h选项出现在最后,打印帮助信息
        } else if (!strcmp(argv[i],"--help")) {   // 对于--help选项,直接打印帮助信息
            usage();
        } else if (!strcmp(argv[i],"-x")) {
            config.stdinarg = 1;
        } else if (!strcmp(argv[i],"-p") && !lastarg) {
            config.hostport = atoi(argv[++i]);    // -p选项,不能在最后一个位置,要带上参数
        } else if (!strcmp(argv[i],"-s") && !lastarg) {
            config.hostsocket = argv[++i];        // 指定socket位置
        } else if (!strcmp(argv[i],"-r") && !lastarg) {
            config.repeat = strtoll(argv[++i],NULL,10);  // 指定重复次数
        } else if (!strcmp(argv[i],"-i") && !lastarg) {
            double seconds = atof(argv[++i]);     // 转成整型值
            config.interval = seconds*1000000;    // 转成微秒数
        } else if (!strcmp(argv[i],"-n") && !lastarg) {
            config.dbnum = atoi(argv[++i]);       // 选定操作的Redis数据库编号
        } else if (!strcmp(argv[i],"-a") && !lastarg) {
            config.auth = argv[++i];              // -a选项用于指定Redis服务的认证密码
        } else if (!strcmp(argv[i],"--raw")) {
            config.output = OUTPUT_RAW;
        } else if (!strcmp(argv[i],"--csv")) {
            config.output = OUTPUT_CSV;
        } else if (!strcmp(argv[i],"--latency")) {
            config.latency_mode = 1;
        } else if (!strcmp(argv[i],"--latency-history")) {
            config.latency_mode = 1;
            config.latency_history = 1;
        } else if (!strcmp(argv[i],"--slave")) {
            config.slave_mode = 1;
        } else if (!strcmp(argv[i],"--stat")) {
            config.stat_mode = 1;
        } else if (!strcmp(argv[i],"--rdb") && !lastarg) {
            config.getrdb_mode = 1;
            config.rdb_filename = argv[++i];
        } else if (!strcmp(argv[i],"--pipe")) {
            config.pipe_mode = 1;
        } else if (!strcmp(argv[i],"--pipe-timeout") && !lastarg) {
            config.pipe_timeout = atoi(argv[++i]);
        } else if (!strcmp(argv[i],"--bigkeys")) {
            config.bigkeys = 1;
        } else if (!strcmp(argv[i],"--eval") && !lastarg) {
            config.eval = argv[++i];
        } else if (!strcmp(argv[i],"-c")) {
            config.cluster_mode = 1;
        } else if (!strcmp(argv[i],"-d") && !lastarg) {
            sdsfree(config.mb_delim);
            config.mb_delim = sdsnew(argv[++i]);
        } else if (!strcmp(argv[i],"-v") || !strcmp(argv[i], "--version")) {
            sds version = cliVersion();
            printf("redis-cli %s\n", version);
            sdsfree(version);
            exit(0);
        } else {
            if (argv[i][0] == '-') {
                fprintf(stderr,
                    "Unrecognized option or bad number of args for: '%s'\n",
                    argv[i]);
                exit(1);
            } else {
                /* Likely the command name, stop here. */
                break;
            }
        }
    }
    return i;
}
​
// ...

parseOptions()函数的逻辑非常简单,就是依次处理选项以及对应的值。从函数的源码中可以看到redis-cli工具所支持的选项以及相关选项使用方法。如-h选项,用于指定Redis服务的地址,如果该选项放到最后且不带参数,则其含义同--help一致,用于打印redis-cli的帮助信息。

1.2 延迟模式

继续看main()函数中对于不同选项的处理情况。当输入--latency选项时,会设置config.latency_mode 为1。此时,redis-cli工具会先调用cliConnect()函数连接Redis服务端,然后调用latencyMode()函数。先看cliconnect()函数的实现源码,内容如下:

// ...static int cliConnect(int force) {
    if (context == NULL || force) {
        if (context != NULL)
            redisFree(context);
​
        // 创建套接字并连接服务端,底层同样是使用socket()和connect()函数实现
        // 类似anet.c中的anetTcpGenericConnect()函数
        if (config.hostsocket == NULL) {
            context = redisConnect(config.hostip,config.hostport);
        } else {
            context = redisConnectUnix(config.hostsocket);
        }
​
        if (context->err) {
            // 无法连接Redis服务,打印错误信息、释放空间并返回
            // ...
            return REDIS_ERR;
        }
​
        // 设置长连接,主要是更改套接字的相关属性
        anetKeepAlive(NULL, context->fd, REDIS_CLI_KEEPALIVE_INTERVAL);
​
        // 调用cliAuth()进行认证
        if (cliAuth() != REDIS_OK)
            return REDIS_ERR;
         // 调用cliSelect()选择指定数据库
        if (cliSelect() != REDIS_OK)
            return REDIS_ERR;
    }
    return REDIS_OK;
​
​
// ...

cliConnect()函数的逻辑非常简单,其核心就是调用redisConnect()函数,根据传入的服务端IP字符串以及端口连接Redis服务端。该函数内部调用socket()函数创建套接字以及connect()函数连接服务端,十分类似anet.c中定义的anetTcpGenericConnect()函数。

继续看latencyMode()函数的源码,内容如下:

// ...#define LATENCY_SAMPLE_RATE 10 /* milliseconds. */
#define LATENCY_HISTORY_DEFAULT_INTERVAL 15000 /* milliseconds. */
static void latencyMode(void) {
    redisReply *reply;
    long long start, latency, min = 0, max = 0, tot = 0, count = 0;
    // 通过-i选项传入的值被乘1000000后保存到config.interval中,最后需要转换成毫秒数
    long long history_interval =
        config.interval ? config.interval/1000 :   
                          LATENCY_HISTORY_DEFAULT_INTERVAL;  // 默认是15ms
    double avg;
    long long history_start = mstime(); // 获取当前时间的毫秒数,内部调用gettimeofday()函数
​
    // 如果context为NULL,直接退出; 一般前面调用cliConnect()函数时就会给context赋值
    if (!context) exit(1); 
    while(1) {
        start = mstime();
        // context中的成员变量fd保存了与服务端连接的套接字,这里向服务端发送PING命令
        reply = redisCommand(context,"PING"); 
        if (reply == NULL) {
            fprintf(stderr,"\nI/O error\n");
            exit(1);
        }
        latency = mstime()-start; // 计算发送命令到返回结果的时间
        freeReplyObject(reply);
        count++;
        if (count == 1) {         // 第1次进入循环,初始化相关变量
            min = max = tot = latency;
            avg = (double) latency;
        } else {
            if (latency < min) min = latency;  // 开始更新延迟最小值和最大值
            if (latency > max) max = latency;
            tot += latency;      
            avg = (double) tot/count;          // 计算平均延迟
        }
        // 输出请求的延迟情况,注意通过fflush(stdout)操作会使得输出一致保持在同一位置
        printf("\x1b[0G\x1b[2Kmin: %lld, max: %lld, avg: %.2f (%lld samples)",
            min, max, avg, count);
        fflush(stdout);  
        // 如果参数中有--latency-history选项,会从循环开始的时间点计算
        // history_interval就是-i选项输入的毫秒值或者是默认值15000ms=15s
        if (config.latency_history && mstime()-history_start > history_interval)
        {
            printf(" -- %.2f seconds range\n", (float)(mstime()-history_start)/1000);
            history_start = mstime(); // 重新更新history_start以及min、max、tot和count
            min = max = tot = count = 0;
        }
        usleep(LATENCY_SAMPLE_RATE * 1000);
    }
}
​
// ...

从上面的源码可以看到latencyMode()函数主要是向Redis服务端发送PING命令,然后计算响应延迟。多次发送命令后,还将分别得到响应延迟的最小值、最大值以及平均值,且该循环只会在服务端无法响应时退出,否则会一直发送命令并输出延迟信息。请看redis-cli工具的如下用法:

[root@center redis2.8]# ./redis-cli -i 1 -h 192.168.126.140 --latency
min: 0, max: 1, avg: 0.12 (1292 samples)^C
[root@center redis2.8]#

latencyMode()函数中涉及的核心函数redisCommand()后面会详细介绍,这里只需要了解其基本功能即可。

此外,还有--latency-history选项的作用也能从源码中看到,该选项只会记录在history_interval时间内的响应延时情况,超过这个时间间隔后,原先数据清零(if内的操作),然后重新开始计算。

[root@server2 redis2.8]# ./redis-cli -i 10 --latency-history
min: 0, max: 3, avg: 0.11 (978 samples) -- 10.00 seconds range
min: 0, max: 1, avg: 0.11 (979 samples) -- 10.01 seconds range
min: 0, max: 1, avg: 0.08 (982 samples) -- 10.01 seconds range
min: 0, max: 1, avg: 0.08 (981 samples) -- 10.01 seconds range
min: 0, max: 1, avg: 0.10 (207 samples)^C

1.3 Slave模式

继续看main()函数中的第4处代码,当redis-cli命令中带上--salve选项时,将进入到这里处理。同样时先调用cliConnect()函数连接Redis服务端,然后调用slaveMode()函数。该函数的实现源码如下:

// ...static void slaveMode(void) {
    int fd = context->fd;  // 从context中获取与服务端连接的套接字
    unsigned long long payload = sendSync(fd); // 发送SYNC命令
    char buf[1024];
​
    fprintf(stderr,"SYNC with master, discarding %llu "
                   "bytes of bulk transfer...\n", payload);
​
    /* Discard the payload. */
    while(payload) {
        ssize_t nread;
​
        nread = read(fd,buf,(payload > sizeof(buf)) ? sizeof(buf) : payload);
        if (nread <= 0) {
            fprintf(stderr,"Error reading RDB payload while SYNCing\n");
            exit(1);
        }
        payload -= nread;
    }
    fprintf(stderr,"SYNC done. Logging commands from master.\n");
​
    /* Now we can use hiredis to read the incoming protocol. */
    config.output = OUTPUT_CSV;
    while (cliReadReply(0) == REDIS_OK);
}
​
// ...

slave模式的源码看着逻辑非常清晰:首先调用sendSync()函数发送SYNC命令,该命令会返回Redis的有效载荷数据,即后续服务端将发送指定数量的内容。接着是通过read()函数依次读取这些数据(每次最多接收1024字节)并丢弃,最后调用cliReadReply()函数循环接收后续服务端发送过来的内容。这里可以做一个简单的操作,如下:

  • 首先启动一个redis2.8的服务端后台运行:

    [root@server2 redis2.8]# ./redis-server redis.conf
    
  • 接着执行redis-cli命令并设置--slave选项:

    [root@server2 redis2.8]# ./redis-cli --slave
    SYNC with master, discarding 33 bytes of bulk transfer...
    SYNC done. Logging commands from master.
    "PING"
    "PING"
    "PING"

    可以看到执行上述命令后,客户端程序不会退出并一直阻塞在前台等待接收服务端发回数据。此外,终端每隔10秒会有一个PING字符串输出,这说明服务端有同从节点定时保持心跳的机制。

  • 接着打开另一个窗口运行redis-cli命令,分别执行setget命令,操作如下:

    [root@server2 redis2.8]# ./redis-cli 
    127.0.0.1:6379> set hello world
    OK
    127.0.0.1:6379> get hello
    "world"
    127.0.0.1:6379> exit
    

    接着回调启动slave模式的客户端工具窗口,可以看到相应的输入内容:

    [root@server2 redis2.8]# ./redis-cli --slave
    SYNC with master, discarding 33 bytes of bulk transfer...
    SYNC done. Logging commands from master.
    "PING"
    "PING"
    "PING"
    "SELECT","0"
    "set","hello","world"
    "PING"
    "PING"
    ^C
    

    从上面的输出中可以看到,启动了salve模式的客户端会将其当作Redis服务端的从节点,除了正常定时的心跳命令外,还会记录其他客户端给Redis服务发送的相关操作(对于get操作并不记录,因为不会影响内存中的数据)。

继续回到salveMode()函数的源码中,其中最重要的两个函数调用分别为sendSync()cliReadReply(),而后者会在后面研究接收服务端返回数据时统一介绍到。以下是sendSync()函数源码:

// ...
​
unsigned long long sendSync(int fd) {
    char buf[4096], *p;
    ssize_t nread;
​
    /* Send the SYNC command. */
    if (write(fd,"SYNC\r\n",6) != 6) {
        fprintf(stderr,"Error writing to master\n");
        exit(1);
    }
​
    /* Read $<payload>\r\n, making sure to read just up to "\n" */
    p = buf;
    while(1) {
        nread = read(fd,p,1);
        if (nread <= 0) {
            fprintf(stderr,"Error reading bulk length while SYNCing\n");
            exit(1);
        }
        if (*p == '\n' && p != buf) break;
        if (*p != '\n') p++;
    }
    *p = '\0';
    if (buf[0] == '-') {
        printf("SYNC with master failed: %s\n", buf);
        exit(1);
    }
    return strtoull(buf+1,NULL,10); // 字符串转成无符号长整型值,跳过第1个字符
}
​
// ...

sendSync()函数的逻辑也是非常清晰,直接调用write()函数向连接的服务端(通过套接字fd)写入Redis同步SYNC\r\n,然后接收类似$<payload>\r\n这样的返回结果。首先处理是搜索返回字符串,找到\n所在位置后退出循环,接着第就是将换行符替换成字符串终止符,接下来是判断接收数据缓冲区buf是否以-开头,根据Redis的通信协议,返回-开头的字符串表示命令执行错误。如果是正确情况,将返回的字符串转换成无符号长整型的值并返回。

1.4 获取持久化数据

main()函数的第5处代码中,如果在使用redis-cli命令时使用了--rdb选项,则将调用getRDB()函数从服务端获取rdb文件。该函数的实现源码如下:

// ...
​
static void getRDB(void) {
    int s = context->fd;
    int fd;
    unsigned long long payload = sendSync(s); // 同样发送同步命令
    char buf[4096];
​
    fprintf(stderr,"SYNC sent to master, writing %llu bytes to '%s'\n",
        payload, config.rdb_filename);
​
    if (!strcmp(config.rdb_filename,"-")) {    // 打开指定文件,如果没有指定,则fd为标准输出
        fd = STDOUT_FILENO;   
    } else {
        fd = open(config.rdb_filename, O_CREAT|O_WRONLY, 0644);
        if (fd == -1) {
            fprintf(stderr, "Error opening '%s': %s\n", config.rdb_filename,
                strerror(errno));
            exit(1);
        }
    }
    // 处理同步数据
    while(payload) {
        ssize_t nread, nwritten;
        // 每次最多读取4096字节数据
        nread = read(s,buf,(payload > sizeof(buf)) ? sizeof(buf) : payload);
        if (nread <= 0) {
            fprintf(stderr,"I/O Error reading RDB payload from socket\n");
            exit(1);
        }
        // 将从服务端读取的数据写入到文件中
        nwritten = write(fd, buf, nread);
        // 如果从服务端读取的数据和写入到本地文件的字节数不一致,输出错误信息并退出
        if (nwritten != nread) {
            fprintf(stderr,"Error writing data to file: %s\n",
                strerror(errno));
            exit(1);
        }
        payload -= nread;
    }
    close(s); /* Close the file descriptor ASAP as fsync() may take time. */
    fsync(fd);
    fprintf(stderr,"Transfer finished with success.\n");
    exit(0);
}
​
// ...

getRDB()函数的源码中可知道SYNC命令返回的结果正是服务端的内存快照数据,因此getRDB()函数的逻辑非常简单,就是调用sendSync()函数发送SYNC命令并解析响应结果,得到接下来的rbd内容的字节数。接着就是循环接收这些内容并同步写入到本地指定的文件中。这里可以做一个简单的测试,帮助读者理解--rdb选项的作用。具体操作如下:

[root@server2 redis2.8]# ./redis-cli 
127.0.0.1:6379> set hello world
OK
127.0.0.1:6379> set xxxx yyyy
OK
127.0.0.1:6379> set xyz abc
OK
127.0.0.1:6379> keys *
1) "xxxx"
2) "hello"
3) "xyz"
127.0.0.1:6379> exit
[root@server2 redis2.8]# ./redis-cli --rdb myrdb 
SYNC sent to master, writing 53 bytes to 'myrdb'
Transfer finished with success.
[root@server2 redis2.8]# ./redis-check-dump myrdb 
==== Processed 5 valid opcodes (in 36 bytes) ===================================
CRC64 checksum is OK
[root@server2 redis2.8]# od -A x -t x1c -v myrdb
000000  52  45  44  49  53  30  30  30  36  fe  00  00  04  78  78  78
         R   E   D   I   S   0   0   0   6 376  \0  \0 004   x   x   x
000010  78  04  79  79  79  79  00  05  68  65  6c  6c  6f  05  77  6f
         x 004   y   y   y   y  \0 005   h   e   l   l   o 005   w   o
000020  72  6c  64  00  03  78  79  7a  03  61  62  63  ff  13  1e  dc
         r   l   d  \0 003   x   y   z 003   a   b   c 377 023 036 334
000030  d3  31  73  22  8d
       323   1   s   " 215
000035

在上述操作中,笔者先通过redis-cli工具向服务端写入三个key-value数据,然后继续使用redis-cli命令带上--rdb选项从服务端获取当前内存的快照数据并保存到本地的myrdb文件中。由于myrdb是二进制文件,所以不能直接通过cat命令查看其内容,而是可以通过redis-check-dump工具对其进行校验以及od命令输出该文件中每个字节的详情。

1.5 流水线模式

继续看main()函数中的第6处代码,当redis-cli命令带上选项--pipe后,config的成员变量pipe_mode就被设置为1,此时将调用pipeMode()函数。该函数的实现源码如下:

// ...
​
static void pipeMode(void) {
    int fd = context->fd;
    long long errors = 0, replies = 0, obuf_len = 0, obuf_pos = 0;
    char ibuf[1024*16], obuf[1024*16]; // 输入和输出缓冲区
    char aneterr[ANET_ERR_LEN];
    redisReader *reader = redisReaderCreate();
    redisReply *reply;
    int eof = 0;    // 一旦消耗完标准输入的内容,就会将其设置为1
    int done = 0;
    char magic[20]; // 识别我们自己的响应
    time_t last_read_time = time(NULL);
​
    srand(time(NULL));
​
    // 设置非阻塞IO
    if (anetNonBlock(aneterr,fd) == ANET_ERR) {
        fprintf(stderr, "Can't set the socket in non blocking mode: %s\n",
            aneterr);
        exit(1);
    }
​
    while(!done) {
        int mask = AE_READABLE;
        // 如果没有到文件尾或者还有输出的
        if (!eof || obuf_len != 0) mask |= AE_WRITABLE;
        // 最多等待1s,内核返回fd发生的相关事件
        // 如果读事件发生,则mask | AE_READABLE为True,写事件发生,则mask |= AE_WRITABLE为True
        mask = aeWait(fd,mask,1000); 
​
        /* 处理套接字可读状态: 读取从服务端返回的数据 */
        if (mask & AE_READABLE) {
            ssize_t nread;
​
            // 调用read()函数从服务端读取数据,然后保存到redisReader结构体变量中,最终返回的全部内容将保存到reader->buf
            do {
                nread = read(fd,ibuf,sizeof(ibuf));
                if (nread == -1 && errno != EAGAIN && errno != EINTR) {
                    fprintf(stderr, "Error reading from the server: %s\n",
                        strerror(errno));
                    exit(1);
                }
                if (nread > 0) {
                    redisReaderFeed(reader,ibuf,nread);
                    last_read_time = time(NULL);
                }
            } while(nread > 0);
​
            // 处理返回结果
            do {
                if (redisReaderGetReply(reader,(void**)&reply) == REDIS_ERR) {
                    fprintf(stderr, "Error reading replies from server\n");
                    exit(1);
                }
                if (reply) {
                    if (reply->type == REDIS_REPLY_ERROR) {
                        fprintf(stderr,"%s\n", reply->str);
                        errors++;
                    } else if (eof && reply->type == REDIS_REPLY_STRING &&
                                      reply->len == 20) {
                        // 检查是否是redis-cli自己发送的ECHO命令
                        if (memcmp(reply->str,magic,20) == 0) {
                            printf("Last reply received from server.\n");
                            done = 1;
                            replies--;
                        }
                    }
                    replies++;
                    freeReplyObject(reply);
                }
            } while(reply);
        }
​
        if (mask & AE_WRITABLE) {
            while(1) {
                /* 发送当前缓冲区数据到服务端 */
                if (obuf_len != 0) {
                    // obuf_len不为0时,说明此时已经有数据读取,接着就是使用write()将内容直接发给Redis服务端
                    ssize_t nwritten = write(fd,obuf+obuf_pos,obuf_len);
                    
                    if (nwritten == -1) {
                        if (errno != EAGAIN && errno != EINTR) {
                            fprintf(stderr, "Error writing to the server: %s\n",
                                strerror(errno));
                            exit(1);
                        } else {
                            nwritten = 0;
                        }
                    }
                    // 写入成功,记录写入成功字节数,同时更新下一个要发送的位置,即obuf_pos
                    obuf_len -= nwritten;
                    obuf_pos += nwritten;
                    if (obuf_len != 0) break; /* Can't accept more data. */
                }
                /* 如果缓冲区为空,则从stdin读入数据 */
                if (obuf_len == 0 && !eof) {
                    ssize_t nread = read(STDIN_FILENO,obuf,sizeof(obuf)); // 从标准输入读取数据
​
                    if (nread == 0) {
                        /* The ECHO sequence starts with a "\r\n" so that if there
                         * is garbage in the protocol we read from stdin, the ECHO
                         * will likely still be properly formatted.
                         * CRLF is ignored by Redis, so it has no effects. */
                        char echo[] =
                        "\r\n*2\r\n$4\r\nECHO\r\n$20\r\n01234567890123456789\r\n";   
                        int j;                                                       
​
                        eof = 1;
                        /* Everything transferred, so we queue a special
                         * ECHO command that we can match in the replies
                         * to make sure everything was read from the server. */
                        for (j = 0; j < 20; j++)
                            magic[j] = rand() & 0xff;
                        memcpy(echo+21,magic,20);
                        memcpy(obuf,echo,sizeof(echo)-1); // obuf是要发送数据的缓冲区地址
                        obuf_len = sizeof(echo)-1;
                        obuf_pos = 0;
                        printf("All data transferred. Waiting for the last reply...\n");
                    } else if (nread == -1) {
                        fprintf(stderr, "Error reading from stdin: %s\n",
                            strerror(errno));
                        exit(1);
                    } else {
                        obuf_len = nread; // 对于读取到stdin的数据
                        obuf_pos = 0;     // 要发送数据的位置偏移
                    }
                }
                if (obuf_len == 0 && eof) break;
            }
        }
​
        /* Handle timeout, that is, we reached EOF, and we are not getting
         * replies from the server for a few seconds, nor the final ECHO is
         * received. */
        if (eof && config.pipe_timeout > 0 &&
            time(NULL)-last_read_time > config.pipe_timeout)
        {
            fprintf(stderr,"No replies for %d seconds: exiting.\n",
                config.pipe_timeout);
            errors++;
            break;
        }
    }
    redisReaderFree(reader);
    printf("errors: %lld, replies: %lld\n", errors, replies);
    if (errors)
        exit(1);
    else
        exit(0);
}
​
// ...

关于pipeMode()函数的处理细节较为复杂,但是整体逻辑还是比较清晰的。首先是该函数在while循环中调用aeWait()来等待事件发生,发生的事件会保存到mask中。接着就是对fd中发送的读事件(mask & AE_READABLE)和写事件(mask & AE_WRITABLE)依次处理。对于./redis-cli --pipe命令来说,首先会触发套接字的写事件,因为套接字建立好后,写数据缓冲区为空,触发套接字的写事件以向服务端发送数据。在对写事件的处理中,由于一开始obuf_len == 0eof == 0,因此会调用read()函数从STDIN_FILENO中读取数据并保存到obuf缓存区中。在写事件中的下一次循环时,obuf_len不再为0,因此会进入循环的第一个if中处理,而该if中会调用write()函数向连接服务端的套接字fd中写入缓冲区obuf的内容。如果标准输入的数据读取完毕,且eof还是0,程序将进入if (obuf_len == 0 && !eof)语句下执行。在该if语句内会设置eof值为1,同时也给输出缓冲区追加发送ECHO 01234567890123456789命令的字符串。接下来,用于if (obuf_len == 0 && eof)判断为True,此时将跳出读事件处理逻辑中的while循环。

接下来继续进行fd套接字的事件监控,由于前面给Redis服务端发送执行Redis命令的数据后,Redis服务端会返回相应命令的执行结果到接收缓冲区。此时会触发fd的读事件(mask & AE_READABLETrue),然后就是使用read()函数读取数据并进行处理。这里的处理逻辑较为复杂,会涉及到deps/hiredis目录下的源码,而这些会在后续章节中详细介绍到,这里就不再过多描述。

以下是针对--pipe选项的使用示例:

[root@server2 redis2.8]# echo -en '*3\r\n$3\r\nSET\r\n$5\r\nhello\r\n$6\r\nxyz123\r\n*2\r\n$4\r\nincr\r\n$7\r\ncounter\r\n' > pipe.txt
[root@server2 redis2.8]# cat pipe.txt 
*3
$3
SET
$5
hello
$6
xyz123
*2
$4
incr
$7
counter
[root@server2 redis2.8]# cat pipe.txt | ./redis-cli --pipe
All data transferred. Waiting for the last reply...
Last reply received from server.
errors: 0, replies: 2
[root@server2 redis2.8]# ./redis-cli get hello
"xyz123"
[root@server2 redis2.8]# ./redis-cli get counter
"1"

1.6 查找bigkeys

main()函数中的第7处代码用于查找bigkey,它主要依靠调用findBigKeys()函数实现。该函数的实现源码如下:

// ...
​
static void findBigKeys(void) {
    unsigned long long biggest[5] = {0,0,0,0,0};
    unsigned long long samples = 0;
    redisReply *reply1, *reply2, *reply3 = NULL;
    char *sizecmd, *typename[] = {"string","list","set","hash","zset"};
    char *typeunit[] = {"bytes","items","members","fields","members"};
    int type;
​
    // 输出信息
    printf("\n# Press ctrl+c when you have had enough of it... :)\n");
    printf("# You can use -i 0.1 to sleep 0.1 sec every 100 sampled keys\n");
    printf("# in order to reduce server load (usually not needed).\n\n");
    while(1) {
        /* Sample with RANDOMKEY */
        reply1 = redisCommand(context,"RANDOMKEY");  // 先简单发送RANDOMKEY命令
        if (reply1 == NULL) {
            fprintf(stderr,"\nI/O error\n");
            exit(1);
        } else if (reply1->type == REDIS_REPLY_ERROR) {
            fprintf(stderr, "RANDOMKEY error: %s\n",
                reply1->str);
            exit(1);
        } else if (reply1->type == REDIS_REPLY_NIL) {
            fprintf(stderr, "It looks like the database is empty!\n");
            exit(1);
        }
​
        // 通过向Redis服务端发送type命令获取随机key的类型
        reply2 = redisCommand(context,"TYPE %s",reply1->str);
        assert(reply2 && reply2->type == REDIS_REPLY_STATUS);
        samples++;
​
        // 以下是根据key的不同类型设置获取其长度的命令,
        // 比如string类型,通过strlen key可以获取key的长度
        if (!strcmp(reply2->str,"string")) {
            sizecmd = "STRLEN"; 
            type = TYPE_STRING;
        } else if (!strcmp(reply2->str,"list")) {
            sizecmd = "LLEN";
            type = TYPE_LIST;
        } else if (!strcmp(reply2->str,"set")) {
            sizecmd = "SCARD";
            type = TYPE_SET;
        } else if (!strcmp(reply2->str,"hash")) {
            sizecmd = "HLEN";
            type = TYPE_HASH;
        } else if (!strcmp(reply2->str,"zset")) {
            sizecmd = "ZCARD";
            type = TYPE_ZSET;
        } else if (!strcmp(reply2->str,"none")) {
            freeReplyObject(reply1);
            freeReplyObject(reply2);
            continue;
        } else {
            fprintf(stderr, "Unknown key type '%s' for key '%s'\n",
                reply2->str, reply1->str);
            exit(1);
        }
​
        // 接下来就是执行获取随机key长度的命令,然后发送给Redis服务端获取长度结果
        reply3 = redisCommand(context,"%s %s", sizecmd, reply1->str);
        if (reply3 && reply3->type == REDIS_REPLY_INTEGER) {  // 返回结果的类型必须是整型
            // 如果本次随机找出的key长度大于之前记录的最大长度(同一类型),则输出一个
            if (biggest[type] < reply3->integer) {   
                printf("Biggest %-6s found so far '%s' with %llu %s.\n",
                    typename[type], reply1->str,
                    (unsigned long long) reply3->integer,
                    typeunit[type]);
                biggest[type] = reply3->integer;  // 重新更新当前类型的最大值情况
            }
        }
​
        if ((samples % 1000000) == 0)
            printf("(%llu keys sampled)\n", samples);
​
        if ((samples % 100) == 0 && config.interval)
            usleep(config.interval);
​
        // 释放空间
        freeReplyObject(reply1);
        freeReplyObject(reply2);
        if (reply3) freeReplyObject(reply3);
    }
}
​
// ...

以上代码展示了执行./redis-cli --bigkeys命令时的核心过程。首先findBigKeys()函数内会向Redis服务端发送randomkey命令,该命令将从Redis数据库中随机获取一个key。接着再向Redis发送type key命令查询该key的类型,根据key的类型找到获取该key长度的命令,最后再发送查询该key长度的命令获取其长度。接下来就是再数组中查找对应类型的键所记录的最大长度,如果本次查询的key的长度大于之前记录的长度,则更新该类型所在位置的值并打印一条信息。以上过程会不停循环,由于每次随机取值,因此对于每次输出的key的最大值情况并不唯一。请看如下操作:

[root@server2 redis2.8]# ./redis-cli --bigkeys# Press ctrl+c when you have had enough of it... :)
# You can use -i 0.1 to sleep 0.1 sec every 100 sampled keys
# in order to reduce server load (usually not needed).
​
Biggest string found so far 'xyz' with 3 bytes.
Biggest string found so far 'xxxx' with 4 bytes.
Biggest string found so far 'hello' with 5 bytes.
^C
[root@server2 redis2.8]# ./redis-cli --bigkeys# Press ctrl+c when you have had enough of it... :)
# You can use -i 0.1 to sleep 0.1 sec every 100 sampled keys
# in order to reduce server load (usually not needed).
​
Biggest string found so far 'hello' with 5 bytes.
^C

从以上操作实例中可以看到,当前Redis数据库中的键有3个,其值的长度分别为3、4和5字节。第1次操作的循环中分别随机找出keyxyzxxxxhello,由于这些键对应的长度依次增加,所以每次找到的键都是当前最大的,故而都会有输出。而第二次执行./redis-cli --bigkeys命令时,第1次循环就找到了最长值的键hello,后续中检查的键xyzxxxx,其值长度均小于键hello的值,因此不会有这两个键的打印信息。以下展示了在findBigKeys()函数的循环语句中的同等操作:

[root@server2 redis2.8]# ./redis-cli 
127.0.0.1:6379> randomkey
"hello"
127.0.0.1:6379> type hello
string
127.0.0.1:6379> strlen hello
(integer) 5
127.0.0.1:6379>

1.7 状态展示

main()函数中的第8处代码主要是用于展示当前连接Redis服务的状态,例如下面的实例:

root@server2 redis2.8]# ./redis-cli --stat
------- data ------ --------------------- load -------------------- - child -
keys       mem      clients blocked requests            connections          
3          1.85M    2       0       11113 (+0)          21          
3          1.85M    2       0       11114 (+1)          21          
3          1.85M    2       0       11115 (+1)          21          
3          1.85M    2       0       11116 (+1)          21          
3          1.85M    2       0       11117 (+1)          21          
3          1.85M    2       0       11118 (+1)          21          
^C

上述展示内容主要是调用statMode()函数输出的。相关函数源码如下:

// ...
​
static void statMode() {
    redisReply *reply;
    long aux, requests = 0;
    int i = 0;
​
    while(1) {  // 无限循环
        char buf[64];
        int j;
​
        reply = reconnectingInfo(); // 执行info命令,然后从Redis服务端得到相关信息
        if (reply->type == REDIS_REPLY_ERROR) {
            printf("ERROR: %s\n", reply->str);
            exit(1);
        }
​
        // 先打印输出标题信息,每隔20行才再次输出一次头信息
        if ((i++ % 20) == 0) {
            printf(
"------- data ------ --------------------- load -------------------- - child -\n"
"keys       mem      clients blocked requests            connections          \n");
        }
​
        /* Keys */
        aux = 0;
        for (j = 0; j < 20; j++) {  // 扫描所有数据库
            long k;
​
            sprintf(buf,"db%d:keys",j);
            // 可以直接观察info命令的输出结果,看保存keys信息的位置
            k = getLongInfoField(reply->str,buf); 
            if (k == LONG_MIN) continue;
            aux += k;
        }
        sprintf(buf,"%ld",aux);
        printf("%-11s",buf);
​
        /* Used memory */
        aux = getLongInfoField(reply->str,"used_memory");
        bytesToHuman(buf,aux);
        printf("%-8s",buf);
​
        /* Clients */
        aux = getLongInfoField(reply->str,"connected_clients");
        sprintf(buf,"%ld",aux);
        printf(" %-8s",buf);
​
        /* Blocked (BLPOPPING) Clients */
        aux = getLongInfoField(reply->str,"blocked_clients");
        sprintf(buf,"%ld",aux);
        printf("%-8s",buf);
​
        /* Requets */
        aux = getLongInfoField(reply->str,"total_commands_processed");
        sprintf(buf,"%ld (+%ld)",aux,requests == 0 ? 0 : aux-requests);
        printf("%-19s",buf);
        requests = aux;
​
        /* Connections */
        aux = getLongInfoField(reply->str,"total_connections_received");
        sprintf(buf,"%ld",aux);
        printf(" %-12s",buf);
​
        /* Children */
        aux = getLongInfoField(reply->str,"bgsave_in_progress");
        aux |= getLongInfoField(reply->str,"aof_rewrite_in_progress") << 1;
        switch(aux) {
        case 0: break;
        case 1:
            printf("SAVE");
            break;
        case 2:
            printf("AOF");
            break;
        case 3:
            printf("SAVE+AOF");
            break;
        }
​
        printf("\n");
        freeReplyObject(reply);
        usleep(config.interval);
    }
}
​
// ...

statMode()函数中主要是通过调用reconnectingInfo()函数获取Redis服务端的信息,而该函数的核心内容就是向服务端发送info命令。例如下面的操作:

[root@server2 redis2.8]# ./redis-cli 
127.0.0.1:6379> info
# Server
redis_version:2.8.0
redis_git_sha1:00000000
redis_git_dirty:0
redis_build_id:1bc67e3070c522a8
redis_mode:standalone
os:Linux 3.10.0-1127.13.1.el7.x86_64 x86_64
arch_bits:64
multiplexing_api:epoll
gcc_version:4.8.5
process_id:18034
run_id:a5196aa35e94d3f3aaecc61ff67587d8d1f651cb
tcp_port:6379
uptime_in_seconds:25611
uptime_in_days:0
hz:10
lru_clock:484347
config_file:/root/qicai/redis-learning/redis2.8/redis.conf# Clients
connected_clients:2       
client_longest_output_list:0
client_biggest_input_buf:0
blocked_clients:0# Memory
used_memory:891808
used_memory_human:870.91K
used_memory_rss:1961984
used_memory_peak:1939440
used_memory_peak_human:1.85M
used_memory_lua:33792
mem_fragmentation_ratio:2.20
mem_allocator:libc# Persistence
loading:0
rdb_changes_since_last_save:0
rdb_bgsave_in_progress:0
rdb_last_save_time:1661589172
rdb_last_bgsave_status:ok
rdb_last_bgsave_time_sec:0
rdb_current_bgsave_time_sec:-1
aof_enabled:0
aof_rewrite_in_progress:0
aof_rewrite_scheduled:0
aof_last_rewrite_time_sec:-1
aof_current_rewrite_time_sec:-1
aof_last_bgrewrite_status:ok# Stats
total_connections_received:22
total_commands_processed:11119
instantaneous_ops_per_sec:0
rejected_connections:0
sync_full:7
sync_partial_ok:0
sync_partial_err:0
expired_keys:0
evicted_keys:0
keyspace_hits:5
keyspace_misses:1
pubsub_channels:0
pubsub_patterns:0
latest_fork_usec:140# Replication
role:master
connected_slaves:0
master_repl_offset:19094
repl_backlog_active:0
repl_backlog_size:1048576
repl_backlog_first_byte_offset:14055
repl_backlog_histlen:5040# CPU
used_cpu_sys:2.74
used_cpu_user:4.09
used_cpu_sys_children:0.00
used_cpu_user_children:0.01# Keyspace
db0:keys=3,expires=0,avg_ttl=0
127.0.0.1:6379> 

statMode()函数的后面部分内容就是对得到的返回信息进行解析,从而得到相关信息的值,如keysmem等。在该函数中主要使用了getLongInfoField()函数从返回的Redis信息字符串中抓取关键参数对应的值。该函数的实现源码如下:

// ...
​
static char *getInfoField(char *info, char *field) {
    char *p = strstr(info,field);  // strstr()函数在字符串info中搜索field字符串
    char *n1, *n2;
    char *result;
​
    if (!p) return NULL;          // 如果搜索不到该字符,直接返回
    // 需要值正好在该字符串后面,例如: "db0:keys=3,expires=0,avg_ttl=0"
    p += strlen(field)+1;         
    n1 = strchr(p,'\r');   // 遇到下一个'\r'或者是','号停止,之前的值就是我们想要的
    n2 = strchr(p,',');
    if (n2 && n2 < n1) n1 = n2;  // 选出最近出现的字符
    result = malloc(sizeof(char)*(n1-p)+1);  // 给result位置分配空间
    memcpy(result,p,(n1-p));     // 将得到的值拷贝到指定位置
    result[n1-p] = '\0';         // 最后加上字符串终止符
    return result;               // 返回该位置
}
​
// ...

getInfoField()函数的处理逻辑非常清晰,就是从字符串info中搜索关键字field的位置,它的下一个字符应该是冒号(:),因此从冒号后面开始到第一次出现\r或者是逗号(,)截至,期间出现的字符串正是该关键字对应的值。然后就是提取这一部分的内容保存到result所指向的空间,最后添加了字符串终止符。

1.8 进入交互模式

redis-cli命令的交互模式是运维人员最常用的模式,其相关功能由repl()函数实现,包括实现交互格式、等待输入以及显示Redis服务端的响应结果等。当然,在进入交互模式之前会调用cliConnect()函数连接服务端。以下是repl()函数的源码:

// ...#define LINE_BUFLEN 4096
static void repl() {
    sds historyfile = NULL;
    int history = 0;
    char *line;
    int argc;
    sds *argv;
​
    config.interactive = 1;
    // 注册输入tab补全命令时的回调函数
    linenoiseSetCompletionCallback(completionCallback);
​
    if (isatty(fileno(stdin))) {
        history = 1;
​
        if (getenv("HOME") != NULL) {
            historyfile = sdscatprintf(sdsempty(),"%s/.rediscli_history",getenv("HOME"));
            linenoiseHistoryLoad(historyfile);
        }
    }
​
    cliRefreshPrompt(); // 输出交互模式下的提示符
    while((line = linenoise(context ? config.prompt : "not connected> ")) != NULL) {
        if (line[0] != '\0') {
            argv = sdssplitargs(line,&argc);
            if (history) linenoiseHistoryAdd(line);
            if (historyfile) linenoiseHistorySave(historyfile);
​
            if (argv == NULL) {
                printf("Invalid argument(s)\n");
                free(line);
                continue;
            } else if (argc > 0) {
                if (strcasecmp(argv[0],"quit") == 0 ||
                    strcasecmp(argv[0],"exit") == 0)
                {
                    exit(0);
                } else if (argc == 3 && !strcasecmp(argv[0],"connect")) {
                    sdsfree(config.hostip);
                    config.hostip = sdsnew(argv[1]);
                    config.hostport = atoi(argv[2]);
                    cliRefreshPrompt();
                    cliConnect(1);
                } else if (argc == 1 && !strcasecmp(argv[0],"clear")) {
                    linenoiseClearScreen();
                } else {
                    long long start_time = mstime(), elapsed;
                    int repeat, skipargs = 0;
​
                    repeat = atoi(argv[0]);
                    if (argc > 1 && repeat) {
                        skipargs = 1;
                    } else {
                        repeat = 1;
                    }
​
                    while (1) {
                        config.cluster_reissue_command = 0;
                        if (cliSendCommand(argc-skipargs,argv+skipargs,repeat)
                            != REDIS_OK)
                        {
                            cliConnect(1);
​
                            /* If we still cannot send the command print error.
                             * We'll try to reconnect the next time. */
                            if (cliSendCommand(argc-skipargs,argv+skipargs,repeat)
                                != REDIS_OK)
                                cliPrintContextError();
                        }
                        if (config.cluster_mode && config.cluster_reissue_command) {
                            cliConnect(1);
                        } else {
                            break;
                        }
                    }
                    elapsed = mstime()-start_time;
                    if (elapsed >= 500) {
                        printf("(%.2fs)\n",(double)elapsed/1000);
                    }
                }
            }
            /* Free the argument vector */
            sdsfreesplitres(argv,argc);
        }
        /* linenoise() returns malloc-ed lines like readline() */
        free(line);
    }
    exit(0);
}
​
// ...

上面的源码中有几个非常有意思的函数:

  • linenoiseSetCompletionCallback():设置Tab补全的回调函数;
  • cliRefreshPrompt():输出交互的提示字符串;
  • linenoise():读取一行内容;

如果熟悉Redis1.3.6源码的同学应该知道,在Redis1.3.6中的交互模式做的体验非常差,在交互模式下没有历史命令、输入内容无法回退清除等等。而这些问题统统在Redis2.8中完美解决,甚至在Redis5.0之后添加了更加人性化的体验。由于linenoiseSetCompletionCallback()linenoise()函数来自deps/hiredis目录下的源码,这里先暂时不介绍。以下时cliRefreshPrompt()函数的实现源码:

// ...static void cliRefreshPrompt(void) {
    int len;
​
    if (config.hostsocket != NULL)
        len = snprintf(config.prompt,sizeof(config.prompt),"redis %s",
                       config.hostsocket);
    else
        len = snprintf(config.prompt,sizeof(config.prompt),
                       strchr(config.hostip,':') ? "[%s]:%d" : "%s:%d",
                       config.hostip, config.hostport);
    /* Add [dbnum] if needed */
    if (config.dbnum != 0)
        len += snprintf(config.prompt+len,sizeof(config.prompt)-len,"[%d]",
            config.dbnum);
    snprintf(config.prompt+len,sizeof(config.prompt)-len,"> ");
}
​
// ...

当在终端输入./redis-cli命令时,通过调用cliRefreshPrompt()函数得到了类似"127.0.0.1:6379> "或者是"127.0.0.1:6379[1]> "这样的字符串,该提示字符串的首地址保存在config.prompt变量中。接下来在linenoise()函数中打印该提示语句,如果没有同服务端连接,则会输出类似"not connected> "这样的字符串提示。

linenoise()函数在输出提示符的同时会等待用户输入·Redis命令并按下回车,相关输入内容将保存到该函数的返回值,即line变量中(会去掉末尾的换行符)。如果line为空字符串,即用户没有输入直接按下回车,则继续回到while的条件判断,继续调用linenoise()函数输出提示符后等待用户输入。

如果用户输入类似set hello world这样正常的Redis命令,将对该字符串进行切割,然后判断第一个单词是否是一些特殊命令。例如,连接命令(connect)、退出命令(exitquit)以及清屏命令(clear)等。如果是特殊命令将特殊处理,如果不是,则会调用最核心的cliSendCommand()函数向服务端发送命令以及处理对应命令的响应值。以下是该核心函数的实现源码:

// ...
​
static int cliSendCommand(int argc, char **argv, int repeat) {
    char *command = argv[0]; // 保存第一个命令
    size_t *argvlen;
    int j, output_raw;
​
    if (!strcasecmp(command,"help") || !strcasecmp(command,"?")) {
        cliOutputHelp(--argc, ++argv);
        return REDIS_OK;
    }
​
    if (context == NULL) return REDIS_ERR;
​
    output_raw = 0;
    if (!strcasecmp(command,"info") ||
        (argc == 2 && !strcasecmp(command,"cluster") &&
                      (!strcasecmp(argv[1],"nodes") ||
                       !strcasecmp(argv[1],"info"))) ||
        (argc == 2 && !strcasecmp(command,"client") &&
                       !strcasecmp(argv[1],"list")))
​
    {
        output_raw = 1;
    }
​
    if (!strcasecmp(command,"shutdown")) config.shutdown = 1;
    if (!strcasecmp(command,"monitor")) config.monitor_mode = 1;
    if (!strcasecmp(command,"subscribe") ||
        !strcasecmp(command,"psubscribe")) config.pubsub_mode = 1;
​
    argvlen = malloc(argc*sizeof(size_t));  // 保存argv数组中每个元素的长度到argvlen的对应位置
    for (j = 0; j < argc; j++)
        argvlen[j] = sdslen(argv[j]);
​
    while(repeat--) {
        // 这里将重新组装Redis命令字符串,基于Redis协议
        redisAppendCommandArgv(context,argc,(const char**)argv,argvlen);
        while (config.monitor_mode) {
            if (cliReadReply(output_raw) != REDIS_OK) exit(1);
            fflush(stdout);
        }
​
        // 如果是订阅模式
        if (config.pubsub_mode) {
            if (config.output != OUTPUT_RAW)
                printf("Reading messages... (press Ctrl-C to quit)\n");
            while (1) {
                if (cliReadReply(output_raw) != REDIS_OK) exit(1);
            }
        }
​
        // 在这里将执行发送命令字符串到Redis服务端以及处理返回结果
        if (cliReadReply(output_raw) != REDIS_OK) {
            free(argvlen);
            return REDIS_ERR;
        } else {
            /* Store database number when SELECT was successfully executed. */
            if (!strcasecmp(command,"select") && argc == 2) {
                config.dbnum = atoi(argv[1]);
                cliRefreshPrompt();
            }
        }
        
        if (config.interval) usleep(config.interval);
        fflush(stdout); /* Make it grep friendly */
    }
​
    free(argvlen);
    return REDIS_OK;
}
​
// ...

以在交互模式下输入set hello world命令为例,该字符串按照空格切割后得到数据内容["set", "hello", "world"],也即数组变量argv的值,argc则是表示该数组的长度,它的值为3。同样,cliSendCommand()函数会对输入命令名进行一些简单的判断,如是否为helpinfo等命令。之后将argv中每个元素的长度保存到数组argvlen中。

接下来在while循环中有两个重要函数:

  • redisAppendCommandArgv()函数:该函数将依据Redis协议将命令数组转换成发送给Redis服务端的命令字符串,在调用该函数后,生成的命令字符串将保存在context->obuf变量中;
  • cliReadReply()函数将真正发送命令字符串(保存在context->obuf)到Redis服务端,同时该函数还会接收从服务端返回的响应数据并最终打印出来。

redisAppendCommandArgv()函数的实现源码,内容如下:

// deps/hiredis/hiredis.c
// ...int redisAppendCommandArgv(redisContext *c, int argc, const char **argv, const size_t *argvlen) {
    char *cmd;
    int len;
​
    len = redisFormatCommandArgv(&cmd,argc,argv,argvlen);
    if (len == -1) {
        __redisSetError(c,REDIS_ERR_OOM,"Out of memory");
        return REDIS_ERR;
    }
​
    if (__redisAppendCommand(c,cmd,len) != REDIS_OK) {
        free(cmd);
        return REDIS_ERR;
    }
​
    free(cmd);
    return REDIS_OK;
}
​
// ...

从上面的源码中可知,redisAppendCommandArgv()函数的核心是调用redisFormatCommandArgv()函数处理命令数组,该函数的实现源码如下:

// deps/hiredis/hiredis.c
// ...
​
int redisFormatCommandArgv(char **target, int argc, const char **argv, const size_t *argvlen) {
    char *cmd = NULL; /* final command */
    int pos; /* position in final command */
    size_t len;
    int totlen, j;
​
    /* Calculate number of bytes needed for the command */
    totlen = 1+intlen(argc)+2;
    for (j = 0; j < argc; j++) {
        len = argvlen ? argvlen[j] : strlen(argv[j]);
        totlen += bulklen(len);
    }
​
    /* Build the command at protocol level */
    cmd = malloc(totlen+1);
    if (cmd == NULL)
        return -1;
​
    pos = sprintf(cmd,"*%d\r\n",argc);
    for (j = 0; j < argc; j++) {
        len = argvlen ? argvlen[j] : strlen(argv[j]);
        pos += sprintf(cmd+pos,"$%zu\r\n",len);
        memcpy(cmd+pos,argv[j],len);
        pos += len;
        cmd[pos++] = '\r';
        cmd[pos++] = '\n';
    }
    assert(pos == totlen);
    cmd[pos] = '\0';
​
    *target = cmd;
    return totlen;
}
​
// ...

从上述源码中可以看到命令字符串数组是如何一步一步转换成发送给Redis服务的命令字符串的完整过程。这里笔者以set hello world命令为例进行说明。此时,根据前面分析可知,传给redisFormatCommandArgv()函数的后三个参数值如下:

argc = 3
argv = ["set", "hello", "world"]
argvlen = [3, 5, 5]

redisFormatCommandArgv()函数首先计算最终生成的命令字符串的总大小,这里用到了一个简单的bulklen()函数。接着就是给最终要生成的命令字符串分配相应的空间,其大小正是前面计算的总大小加1(多一个字节空间用于保存字符串终止符)。最后就是合成这个命令字符串,其步骤如下:

  • 开始时,cmd = "*3\r\n"

  • 根据原来命令数组大小循环:

    • 第1次循环:cmd = "*3\r\n$3\r\nset\r\n"
    • 第2次循环:cmd = "*3\r\n$3\r\nset\r\n$5\r\nhello\r\n";
    • 第3次循环:cmd = "*3\r\n$3\r\nset\r\n$5\r\nhello\r\n$5\r\nworld\r\n";
  • 最后在cmd的最后位置加上字符串终止符。

最终得到的字符串为:"*3\r\n$3\r\nset\r\n$5\r\nhello\r\n$5\r\nworld\r\n",而该字符串所在的地址将赋给*target,也即redisAppendCommandArgv()函数中的cmd指针。

接着在redisAppendCommandArgv()函数中调用__redisAppendCommand()函数,该操作会将生成的命令字符串内容复制到context.obuf变量中。__redisAppendCommand()函数的实现源码如下:

// 
// ...int __redisAppendCommand(redisContext *c, char *cmd, size_t len) {
    sds newbuf;
​
    // cmd指向要发给Redis的命令字符串的地址,这里会新建空间保存,因此cmd指向的空间会在后续释放掉
    newbuf = sdscatlen(c->obuf,cmd,len); 
    if (newbuf == NULL) {
        __redisSetError(c,REDIS_ERR_OOM,"Out of memory");
        return REDIS_ERR;
    }
​
    c->obuf = newbuf;  // c->obuf保存要发给Redis的命令字符串的地址 
    return REDIS_OK;
}
​
// ...

接下来就是追踪cliReadReply()函数的源码,其内容如下:

// ...
​
static int cliReadReply(int output_raw_strings) {
    void *_reply;
    redisReply *reply;
    sds out = NULL;
    int output = 1;
​
    // redisGetReply()函数将向Redis服务端发送命令字符串并接收服务端返回的数据
    if (redisGetReply(context,&_reply) != REDIS_OK) {
        if (config.shutdown)
            return REDIS_OK;
        if (config.interactive) {
            /* Filter cases where we should reconnect */
            if (context->err == REDIS_ERR_IO && errno == ECONNRESET)
                return REDIS_ERR;
            if (context->err == REDIS_ERR_EOF)
                return REDIS_ERR;
        }
        cliPrintContextError();
        exit(1);
        return REDIS_ERR; /* avoid compiler warning */
    }
​
    // 将服务端返回的数据转成redisReply结构体,reply->str保存返回结果字符串
    reply = (redisReply*)_reply; 
​
    // 忽略一些情况
    // ...
​
    if (output) {
        if (output_raw_strings) {
            out = cliFormatReplyRaw(reply);
        } else {
            if (config.output == OUTPUT_RAW) {
                out = cliFormatReplyRaw(reply);
                out = sdscat(out,"\n");
            } else if (config.output == OUTPUT_STANDARD) {
                out = cliFormatReplyTTY(reply,"");
            } else if (config.output == OUTPUT_CSV) {
                out = cliFormatReplyCSV(reply);
                out = sdscat(out,"\n");
            }
        }
        fwrite(out,sdslen(out),1,stdout);
        sdsfree(out);
    }
    freeReplyObject(reply);
    return REDIS_OK;
}
​
// ...

cliReadReply()函数中,其先通过redisGetReply()函数向Redis服务端发送数据并接收其响应字符串。因此,cliReadReply()函数中和Redis服务端打交道的逻辑在redisGetReply()中。以下是该函数的实现源码:

// deps/hiredis/hiredis.c
// ...int redisGetReply(redisContext *c, void **reply) {
    int wdone = 0;
    void *aux = NULL;
​
    /* Try to read pending replies */
    if (redisGetReplyFromReader(c,&aux) == REDIS_ERR)
        return REDIS_ERR;
​
    /* For the blocking context, flush output buffer and read reply */
    if (aux == NULL && c->flags & REDIS_BLOCK) {
        /* Write until done */
        do {
            // 向Redis服务端发送数据,要发送的内容保存在c->obuf变量中
            if (redisBufferWrite(c,&wdone) == REDIS_ERR)
                return REDIS_ERR;
        } while (!wdone);
​
        /* Read until there is a reply */
        do {
            // 接收Redis服务端的响应内容并保存到c->reader->buf中
            if (redisBufferRead(c) == REDIS_ERR)
                return REDIS_ERR;
            if (redisGetReplyFromReader(c,&aux) == REDIS_ERR)
                return REDIS_ERR;
        } while (aux == NULL);
    }
​
    /* Set reply object */
    if (reply != NULL) *reply = aux;
    return REDIS_OK;
}
​
// ...

从上述源码中可以粗略地捋清redisGetReply()函数的执行逻辑。该函数先通过redisBufferWrite()Redis服务端发送数据,直到c->obuf中的字符串内容全部发送完毕。然后再下一个循环中使用redisBufferRead()函数接收从服务端发回来地响应数据。经过一系列处理后,服务端地响应结果最终保存到reply指向的位置,而此处正是redisReply结构体变量的地址。

因此,在cliReadReply()函数中调用redisGetReply()函数发送命令字符串以及接收响应数据后,响应结果保存到reply所指向的redisReply结构体变量的位置。接下来就是调用cliFormatReplyRaw()(或其他函数)来处理响应结果(要根据Redis协议提取出相应字符串内容),并最终将处理后的结果加上换行符后打印在控制台上。cliFormatReplyRaw()函数的实现源码如下:

// ...
​
static sds cliFormatReplyRaw(redisReply *r) {
    sds out = sdsempty(), tmp;
    size_t i;
​
    switch (r->type) {
    case REDIS_REPLY_NIL:
        /* Nothing... */
        break;
    case REDIS_REPLY_ERROR:
        out = sdscatlen(out,r->str,r->len);
        out = sdscatlen(out,"\n",1);
        break;
    case REDIS_REPLY_STATUS:
    case REDIS_REPLY_STRING:  // 返回类型为字符串类型
        out = sdscatlen(out,r->str,r->len);
        break;
    case REDIS_REPLY_INTEGER: // 返回类型为整型
        out = sdscatprintf(out,"%lld",r->integer);
        break;
    case REDIS_REPLY_ARRAY:   // 返回为数组类型
        for (i = 0; i < r->elements; i++) {
            if (i > 0) out = sdscat(out,config.mb_delim);
            tmp = cliFormatReplyRaw(r->element[i]); // 递归调用
            out = sdscatlen(out,tmp,sdslen(tmp));
            sdsfree(tmp);
        }
        break;
    default:
        fprintf(stderr,"Unknown reply type: %d\n", r->type);
        exit(1);
    }
    return out;
}
​
// ...

整体来看,对于repl()函数还有许多代码细节没有理清楚,由于需要依赖函数全部位于deps/hiredis/目录下,这些内容将留到后面分析。目前只需要理解redis-cli工具中交互模式的工作原理即可,包括如何生成交互模式的提示格式,如何发送交互模式下输入的Redis命令以及如何显示Redis响应数据。

1.9 最后剩余参数

最后对于argv参数没有处理完毕的情况将进入main()函数的第10处代码。当redis-cli命令带上--eval选项和不带该选项时,将分别调用evalMode()函数和noninteractive()函数处理剩余参数。先看evalMode()函数的实现源码,内容如下:

// ...
​
static int evalMode(int argc, char **argv) {
    sds script = sdsempty();
    FILE *fp;
    char buf[1024];
    size_t nread;
    char **argv2;
    int j, got_comma = 0, keys = 0;
​
    fp = fopen(config.eval,"r");  // 从这里可以看到--eval选项后面跟的应该是文件名
    if (!fp) {
        fprintf(stderr,
            "Can't open file '%s': %s\n", config.eval, strerror(errno));
        exit(1);
    }
    // 读取文件内容
    while((nread = fread(buf,1,sizeof(buf),fp)) != 0) {
        script = sdscatlen(script,buf,nread);
    }
    fclose(fp);
​
    // 创建Redis命令,用eval命令执行前面读取的文件内容
    argv2 = zmalloc(sizeof(sds)*(argc+3));
    argv2[0] = sdsnew("EVAL");
    argv2[1] = script;
    for (j = 0; j < argc; j++) {  
        // 发现单独的逗号
        if (!got_comma && argv[j][0] == ',' && argv[j][1] == 0) {
            got_comma = 1; 
            continue;
        }
        // 用+3的原因是argv2[0]保存eval字符串,argv2[1]保存脚本内容,argv[2]保存参数的长度
        argv2[j+3-got_comma] = sdsnew(argv[j]); // 保留命令行剩余参数
        if (!got_comma) keys++; // 记录keys数
    }
    // 在后面加上参数的长度信息,对应看下面的eval命令用法
    argv2[2] = sdscatprintf(sdsempty(),"%d",keys); 
​
    // 执行eval命令
    return cliSendCommand(argc+3-got_comma, argv2, config.repeat);
}
​
// ...

以上函数需要理解Rediseval的用法,下面是一些简单的示例演示:

[root@server2 redis2.8]# ./redis-cli
127.0.0.1:6379> get hello
"world"
127.0.0.1:6379> eval "return redis.call('set', 'hello', 'lua')" 0
OK
127.0.0.1:6379> get hello
"lua"
127.0.0.1:6379> eval "return redis.call('set', KEYS[1], KEYS[2])" 2 hello xyz
OK
127.0.0.1:6379> get hello
"xyz"

evalMode()函数的逻辑比较清晰,就是根据--eval选项后面指定的脚本名称,读取其内容,然后加上redis-cli命令剩余参数组成完成的eval命令,最后发送给Redis服务端并打印返回结果。以下是--eval选项的一个用法示例:

[root@server2 redis2.8]# ./redis-cli get hello            # 先查看数据库中键hello对应的值
"world"
[root@server2 redis2.8]# cat script.lua 
return redis.call('set', 'hello', 'bar')
[root@server2 redis2.8]# ./redis-cli --eval script.lua    # 执行脚本内容成功
OK
[root@server2 redis2.8]# ./redis-cli get hello
"bar"
[root@server2 redis2.8]# cat script.lua                   # 改变脚本内容
return redis.call('set', KEYS[1], KEYS[2])
[root@server2 redis2.8]# ./redis-cli --eval script.lua hello aaaa
OK
[root@server2 redis2.8]# ./redis-cli get hello
"aaaa"

上述操作中笔者先查看了Redis数据库中hello键对应的值,然后调用--eval执行lua脚本script.lua。第2此操作中,笔者调整script.lua脚本内容,将设置的keyvalue参数全部用KEYS[1]KEYS[2]代替,此时根据前面的源码可知,最终发送给Redis服务端的命令为:

eval "return redis.call('set', KEYS[1], KEYS[2])" 2 hello aaaa

该命令会设置Redis数据库中键hello的值为aaaa

接下来再没有输入--eval选项时,如果redis-cli还有未处理的选项,则会将剩余选项当作Redis命令执行,调用的处理函数为noninteractive()。该函数的实现源码如下:

// ...
​
static int noninteractive(int argc, char **argv) {
    int retval = 0;
    if (config.stdinarg) {  // 如果是从stdin中读取命令执行
        argv = zrealloc(argv, (argc+1)*sizeof(char*));
        argv[argc] = readArgFromStdin(); // 等待读取参数并保存到argv[argc]位置
        retval = cliSendCommand(argc+1, argv, config.repeat); // 然后发送命令
    } else {
        // 其余情况将直接将剩余参数当作命令执行
        retval = cliSendCommand(argc, argv, config.repeat);
    }
    return retval;
}
​
// ...

请看如下两种操作示例:

[root@server2 redis2.8]# ./redis-cli get hello
"xyz"
[root@server2 redis2.8]# ./redis-cli set hello abcd
OK
[root@server2 redis2.8]# ./redis-cli get hello
"abcd"
[root@server2 redis2.8]# ./redis-cli -x set hello  # set命令设置的值可以通过stdin输入
helloOK                              

上述两种操作分别对应了noninteractive()函数中的不同分支的代码逻辑。首先是当没有-x选项时,后续剩余的命令行参数将当作Redis命令直接发送给Redis服务端,而redis-cli命令有-x选项时,Redis命令的最后一个参数可通过stdin输入。当输入ctrl+D时,进程将认为输入结束,然后将输入的内容赋给argv[agrc]并将argv数组整体当作Redis命令发送给服务端并在控制台上显示该命令的执行结果。上面最后一个操作中,笔者输入字符串hello,然后按下ctrl+D后会立即输出Ok字符串,这是由Redis服务返回的结果。

1.10 小结

本小节主要介绍了Redis2.8redis-cli.c文件的源码,从源码中可以学到关于redis-cli命令的诸多选项及其含义,也让读者可以理解客户端工具redis-cli是如何与Redis服务端进行交互的,这有助于我们开发属于自己的客户端工具来和Redis服务进行交流。