掘金 后端 ( ) • 2024-04-15 09:28

0. 准备工作

0.1 介绍

  • [ ] TODO:interactive字段含义

redis-cli.c文件是redis客户端的入口文件,主要功能是启动一个命令行工具操作redis服务器,可以通过这个命令行工具执行redis支持的功能,如get,set,save,bgsave(AOF持久化)等等。

客户端的主要逻辑包含解析配置,建立远程连接,发送命令,展示服务端回复。

0.2 结构定义

0.2.1 config redis配置定义

static struct config {
    char *hostip;     // 远程主机ip
    int hostport;     // 远程主机port
    long repeat;      // 重复次数
    int dbnum;        // 选择db(0-15),实际使用中只会使用0号db
	  int interactive;  // todo
	  char *auth;       // 权限验证
} config;

config的定义是客户端需要连接服务器的信息,所以只要有一个redis-cli客户端程序,可以连接到任意可访问的redis服务器。

0.2.2 redisCommand 命令定义

struct redisCommand {
    char *name; // 需要执行的命令
    int arity;  // 命令参数个数选项
    int flags;  // resp协议解析格式
};

redisCommand 描述了一个redis命令名称,这个命令所需要的参数个数以及resp协议的解析格式。

0.3 全局变量

static struct redisCommand cmdTable[] = {
    {"get",2,REDIS_CMD_INLINE},
    {"set",3,REDIS_CMD_BULK},
    {"setnx",3,REDIS_CMD_BULK},
    {"append",3,REDIS_CMD_BULK},
    {"substr",4,REDIS_CMD_INLINE},
    {"del",-2,REDIS_CMD_INLINE},
    {"exists",2,REDIS_CMD_INLINE},
    {"incr",2,REDIS_CMD_INLINE},
    {"decr",2,REDIS_CMD_INLINE},
    {"ping",1,REDIS_CMD_INLINE},
    {"echo",2,REDIS_CMD_BULK},
    {"save",1,REDIS_CMD_INLINE},
    {"bgsave",1,REDIS_CMD_INLINE},
    {"rewriteaof",1,REDIS_CMD_INLINE},
    {"bgrewriteaof",1,REDIS_CMD_INLINE},
    {"shutdown",1,REDIS_CMD_INLINE},
    {"flushdb",1,REDIS_CMD_INLINE},
    {"flushall",1,REDIS_CMD_INLINE},
    {"hgetall",2,REDIS_CMD_INLINE},
    {"hexists",3,REDIS_CMD_BULK},
		......
    {NULL,0,0}
};

cmdTable包含redis所有支持的命令信息,如果有新增的命令,那么在这个地方也需要进行添加。

cmdTable通过数组保存所有支持的命令,查找命令时是通过遍历数组匹配name,知道遍历到NULL结束。

// 查找命令是否存在,存在返回命令信息,不存在返回NULL
static struct redisCommand *lookupCommand(char *name) {
    int j = 0;
    while(cmdTable[j].name != NULL) {
        if (!strcasecmp(name,cmdTable[j].name)) return &cmdTable[j];
        j++;
    }
    return NULL;
}

1. 启动

  • [ ] TODO:当配置解析失败后是如何处理的

redis-cli预设了一些默认的配置,当不指定任何配置时,默认会连接本地6379端口的redis服务。

config.hostip = "127.0.0.1";
config.hostport = 6379;
config.repeat = 1;
config.dbnum = 0;
config.interactive = 0;
config.auth = NULL;

bash启动redis-cli时可以通过命令行参数自己指定相关配置,以确定连接的机器以及客户端的相关配置。

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) {
            char *ip = zmalloc(32);
            if (anetResolve(NULL,argv[i+1],ip) == ANET_ERR) {
                printf("Can't resolve %s\n", argv[i]);
                exit(1);
            }
            config.hostip = ip;
            i++;
        } else if (!strcmp(argv[i],"-h") && lastarg) {
            usage();
        } else if (!strcmp(argv[i],"-p") && !lastarg) {
            config.hostport = atoi(argv[i+1]);
            i++;
        } else if (!strcmp(argv[i],"-r") && !lastarg) {
            config.repeat = strtoll(argv[i+1],NULL,10);
            i++;
        } else if (!strcmp(argv[i],"-n") && !lastarg) {
            config.dbnum = atoi(argv[i+1]);
            i++;
        } else if (!strcmp(argv[i],"-a") && !lastarg) {
            config.auth = argv[i+1];
            i++;
        } else if (!strcmp(argv[i],"-i")) {
            config.interactive = 1;
        } else {
            break;
        }
    }
    return i;
}
  • -h && -h是输入的最后一个参数时,表示获取help
  • -h && -h不是最后一个参数时,表示指定的host
  • -p 指定端口
  • -r 指定repeat
  • -n 指定dbnum
  • -a 指定auth
  • -i 指定interactive
# 使用redis-cli的完整命令:
redis-cli -h 127.0.0.1 -p 3306 -r 3 -n 0

配置加载中有一个比较特殊的配置是指定host的时候,会对用户输入的主机进行验证,验证方式就是通过inet_aton进行转换,可以转换就说明格式没有问题,如果不能转换,redis还进行验证提供的是否是一个hostname,通过gethostname获取主机名对应的ip地址。

// 解析host逻辑
if (!strcmp(argv[i],"-h") && !lastarg) {
    char *ip = zmalloc(32);
    if (anetResolve(NULL,argv[i+1],ip) == ANET_ERR) {
        printf("Can't resolve %s\n", argv[i]);
        exit(1);
    }
    config.hostip = ip;
    i++;
}

// 检验host合法性逻辑
int anetResolve(char *err, char *host, char *ipbuf)
{
    struct sockaddr_in sa;

    sa.sin_family = AF_INET;
    if (inet_aton(host, &sa.sin_addr) == 0) { // 当inet_aton返回0,表示无法转换
        struct hostent *he;

        he = gethostbyname(host); // 确定提供的名字是否是一个主机名,通过gethostbyname获取
        if (he == NULL) {
            anetSetError(err, "can't resolve: %s\n", host);
            return ANET_ERR;
        }
        memcpy(&sa.sin_addr, he->h_addr, sizeof(struct in_addr));
    }
    strcpy(ipbuf,inet_ntoa(sa.sin_addr));
    return ANET_OK;
}

解析成功后,进入等待用户命令状态,该状态是一个死循环:等待输入 → 获取一条命令 → 发送到服务端 → 输出服务端响应 → 等待输入…

解析失败后,//todo

2. 输入命令

配置加载完成后,会进入到等待用户输入命令的阶段,在这之前如果有提供auth,需要先向服务端进行验证之后才能进入。

static void repl() {
    int size = 4096, max = size >> 1, argc;
    char buffer[size];
    char *line = buffer;
    char **ap, *args[max];

		// auth验证
    if (config.auth != NULL) {
        char *authargv[2];

        authargv[0] = "AUTH";
        authargv[1] = config.auth;
				// 组织AUTH命令发送到服务端
        cliSendCommand(2, convertToSds(2, authargv));
    }

    while (prompt(line, size)) {
        ...
    }

    exit(0);
}

// 获取用户输入的命令
static char *prompt(char *line, int size) {
    char *retval;

    do {
        printf(">> ");
        retval = fgets(line, size, stdin);
    } while (retval && *line == '\n');
    line[strlen(line) - 1] = '\0';

    return retval;
}
  • 可以看出redis-cli一次输入命令的最大字节数是4096字节
  • 当fgets获取到数据 && 只输入换行时,继续等待输入
  • fgets的返回值:如果成功,返回line的地址,如果失败或读到EOF,返回NULL
  • line[strlen(line)-1] 的位置是\n,将\n覆盖为\0
while (prompt(line, size)) {
	argc = 0;
	
	for (ap = args; (*ap = strsep(&line, " \t")) != NULL;) {
    if (**ap != '\0') {
        if (argc >= max) break;
        if (strcasecmp(*ap,"quit") == 0 || strcasecmp(*ap,"exit") == 0)
            exit(0);
        ap++;
        argc++;
	  }
	}
	config.repeat = 1;
  cliSendCommand(argc, convertToSds(argc, args));
  line = buffer;
}

prompt函数调用完成后line中保存的就是用户输入的命令和命令参数,然后进行拆分,将命令按照空格或者’\t’分割开存储到args数组中,如果输入的命令是quit或exit则redis-cli退出。这个地方将repeat写死为1,意味着无论是否发送成功,只会发送一次。

拆分函数: strsep函数

3. 发送命令

如果指定了auth,第一次发送命令就是auth命令

if (config.auth != NULL) {
	  char *authargv[2];
	
	  authargv[0] = "AUTH";
	  authargv[1] = config.auth;
	  cliSendCommand(2, convertToSds(2, authargv));
}
static int cliSendCommand(int argc, char **argv) {
    struct redisCommand *rc = lookupCommand(argv[0]);
    int fd, j, retval = 0;
    int read_forever = 0;
    sds cmd;

    if (!rc) {
        fprintf(stderr,"Unknown command '%s'\n",argv[0]);
        return 1;
    }

    if ((rc->arity > 0 && argc != rc->arity) ||
        (rc->arity < 0 && argc < -rc->arity)) {
            fprintf(stderr,"Wrong number of arguments for '%s'\n",rc->name);
            return 1;
    }
    if (!strcasecmp(rc->name,"monitor")) read_forever = 1;
    if ((fd = cliConnect()) == -1) return 1;

    /* Select db number */
    retval = selectDb(fd);
    if (retval) {
        fprintf(stderr,"Error setting DB num\n");
        return 1;
    }

    while(config.repeat--) {
        /* Build the command to send */
        cmd = sdsempty();
        if (rc->flags & REDIS_CMD_MULTIBULK) {
            cmd = sdscatprintf(cmd,"*%d\r\n",argc);
            for (j = 0; j < argc; j++) {
                cmd = sdscatprintf(cmd,"$%lu\r\n",
                    (unsigned long)sdslen(argv[j]));
                cmd = sdscatlen(cmd,argv[j],sdslen(argv[j]));
                cmd = sdscatlen(cmd,"\r\n",2);
            }
        } else {
            for (j = 0; j < argc; j++) {
                if (j != 0) cmd = sdscat(cmd," ");
                if (j == argc-1 && rc->flags & REDIS_CMD_BULK) {
                    cmd = sdscatprintf(cmd,"%lu",
                        (unsigned long)sdslen(argv[j]));
                } else {
                    cmd = sdscatlen(cmd,argv[j],sdslen(argv[j]));
                }
            }
            cmd = sdscat(cmd,"\r\n");
            if (rc->flags & REDIS_CMD_BULK) {
                cmd = sdscatlen(cmd,argv[argc-1],sdslen(argv[argc-1]));
                cmd = sdscatlen(cmd,"\r\n",2);
            }
        }
        anetWrite(fd,cmd,sdslen(cmd));
        sdsfree(cmd);

        while (read_forever) {
            cliReadSingleLineReply(fd,0);
        }

        retval = cliReadReply(fd);
        if (retval) {
            return retval;
        }
    }
    return 0;
}

发送命令前的第一件事情是对输入命令的合法性进行验证,通过在redisCommandTable(0.3节)中查询命令的相关设置,第一个作用是确定命令是否存在,第二个作用是获取到这个命令需要的配置。

struct redisCommand {
    char *name; // 命令名称
    int arity;  // 所需要参数数量
    int flags;  // RESP序列化标志,确定哪一种序列化方式
};

/*
{"get",2,REDIS_CMD_INLINE},
{"set",3,REDIS_CMD_BULK},
{"del",-2,REDIS_CMD_INLINE},
{"mset",-3,REDIS_CMD_MULTIBULK},
{"msetnx",-3,REDIS_CMD_MULTIBULK},
*/

可以发现arity字段有正数和负数,并且大小也不统一,这里如果是正数的话,表示参数的数量,比如get key1 这里get命令的参数一共是2个,如果是负数的话,表示的是至少需要多少个参数,如mset key1 val1,这个是最短的命令,也可以mset k1 v1 k2 v2 k3 v3,这样设置多组kv。

验证完参数后就开始链接redis服务器,redis采用TCP协议,客户端作为主动发起连接的一方,调用connect系统调用进行连接。具体TCP socket相关的封装在讲解anet.c文件时进行介绍。

Tcp连接建立完成后,在客户端看来已经可以正常使用redis服务器的能力了

  1. 使用select命令选择db,虽然redis提供多个db,但是在业务使用中基本只会使用0号db
  2. 使用SDS结构(sds.c文件介绍)存储RESP序列化后的字符串
  3. 发送序列化后的字符串到redis-server
  4. 获取回复打印后返回

【序列化协议】RESP(Redis Serialization Protocol) Redis序列化协议_resp序列化-CSDN博客

Redis serialization protocol specification

4. 读取执行结果

客户端发送的命令经过服务端处理后会将数据返回,客户端读取返回的数据打印出来,回复的数据也满足RESP序列化协议,反序列化之后展示服务端回复的内容。

static int cliReadReply(int fd) {
    char type;

    if (anetRead(fd,&type,1) <= 0) exit(1);
    switch(type) {
    case '-':
        printf("(error) ");
        cliReadSingleLineReply(fd,0);
        return 1;
    case '+':
        return cliReadSingleLineReply(fd,0);
    case ':':
        printf("(integer) ");
        return cliReadSingleLineReply(fd,0);
    case '$':
        return cliReadBulkReply(fd);
    case '*':
        return cliReadMultiBulkReply(fd);
    default:
        printf("protocol error, got '%c' as reply type byte\n", type);
        return 1;
    }
}

展示完回复数据后再次进入等待命令输入状态

5.总结

  1. 阅读的代码是redis1.3.6版本,clone之后checkout到当前版本
  2. 介绍了redis-cli获取配置以及解析配置的方式,可以了解到-h选项可以有两种用法
  3. 输入命令部分可以学习到一个命令提示符的实现以及获取输入的方式
  4. 发送命令部分了解了一种验证命令存在与获取命令配置的方式,当然也可以用map记录,更加方便
  5. 获取执行结果部分可以学习到RESP协议的处理细节,如果后续自己想开发redis的客户端,这个必须是要了解的,也可以学习redis中对协议的处理思想
  6. 扩展了解一下RESP协议的具体内容,对自己设计协议也是很有帮助的