redis-cli写入超长转义字符串问题

546 阅读5分钟

一、背景


redis-cli 终端默认只能输入 4095 个字符,如果需要更长的 value 是没有办法的,比如下面的命令(终端里已经无法敲入字符了,因为 SET 命令和 key 的长度,导致 value 只有 4086 个字节):

127.0.0.1:6379> SET test 01234567890123456......
OK
127.0.0.1:6379> STRLEN test
(integer) 4086
127.0.0.1:6379>

不过这个问题可以通过把数据写入文件,通过命令行的方式解决:

[root@b8bd4b93c6cb src]# ./redis-cli SET test `cat ~/tmp.txt`
OK
[root@b8bd4b93c6cb src]# ./redis-cli STRLEN test
(integer) 4100

但是,如果文件中有转义字符的话,上面的方式就无法解决了,例如:

[root@b8bd4b93c6cb src]# cat ~/tmp.txt
0123456789\test
[root@b8bd4b93c6cb src]# ./redis-cli SET test `cat ~/tmp.txt`
OK
[root@b8bd4b93c6cb src]# ./redis-cli GET test
"0123456789\\test"

可以看到,文件中有转义字符的话,最终写入 redis 中会再加一个转义符号 \,导致最终数据和我们想要的不一样。不过 redis-cli 可以写入转义字符:

[root@b8bd4b93c6cb src]# ./redis-cli
127.0.0.1:6379> SET test "0123456789\test"
OK
127.0.0.1:6379> GET test
"0123456789\test"

虽然 redis-cli 可以正常写入转义字符,但是又有 4096 字符数的限制,因此我们需要解决一下这个问题。

二、解决方案


相关代码在 redis-cli.c 文件中,先看下 main 函数,核心逻辑:

  • 初始化配置项,根据 redis-cli 后面参数可以设置相关参数;
  • 根据参数做相应的特殊处理;
  • 没有输入参数,则进入终端交互模式,循环执行 请求/响应 模式;
    • 上面的 可以正常写转义字符串的示例,不过有 4096 大小的限制。
  • 非终端交互模式,直接根据 redis-cli 后面的参数,直接进行处理。
    • 上面的 命令行写文件的示例,不过无法写入原始转义字符串。
int main(int argc, char **argv) {
    // 配置项初始化
    config.hostip = sdsnew("127.0.0.1");
    config.hostport = 6379;
    
    // ...
    
	/* Find big keys */
    if (config.bigkeys) {
        if (cliConnect(0) == REDIS_ERR) exit(1);
        findBigKeys(0, 0);
    }

    // 如果没有参数, 进入交互模式, 默认场景
    if (argc == 0 && !config.eval) {
        signal(SIGPIPE, SIG_IGN);
        cliConnect(0);	// 建立和redis-server的连接
        repl();	// 重复执行客户端输入的命令函数
    }

    if (cliConnect(0) != REDIS_OK) exit(1);
    
    if (config.eval) {
        return evalMode(argc,argv);
    } else {
        // 非终端交互模式, 直接读文件 或者 跟命令模式
        return noninteractive(argc,convertToSds(argc,argv));
    }
}
1、扩大 redis-cli 中 4096 限制

思路是限制了 4096,直接在代码里将对应数字限制改大。redis-cli 终端交互函数为 repl,看下代码:

static void repl(void) {
    // 初始化相关帮助信息
    cliInitHelp();

    // 如果是tty终端, 读取历史命令文件, 方便用户上下找历史命令
    if (isatty(fileno(stdin))) {
        historyfile = getDotfilePath(REDIS_CLI_HISTFILE_ENV,REDIS_CLI_HISTFILE_DEFAULT);
        linenoiseHistoryLoad(historyfile);
    }

    // 循环执行client输入的命令(如果需要扩大最大长度 需要重新编译linenoise.c)
    while((line = linenoise(context ? config.prompt : "not connected> ")) != NULL) {
        if (line[0] != '\0') {
            // ...  执行redis命令
            issueCommandRepeat(argc-skipargs, argv+skipargs, repeat);

        }
        // 释放资源
        linenoiseFree(line);
    }

    exit(0);
}

可以看到,读取终端里用户输入的数据是在 linenoise 函数中实现的。

#define LINENOISE_MAX_LINE 4096

char *linenoise(const char *prompt) {
    char buf[LINENOISE_MAX_LINE];

    if (!isatty(STDIN_FILENO)) {
        // 从文件中读取数据, 不做任何限制
        return linenoiseNoTTY();
        
    } else if (isUnsupportedTerm()) {
        // 不支持的终端 ...
        
    } else {
        count = linenoiseRaw(buf,LINENOISE_MAX_LINE,prompt);
        if (count == -1) return NULL;
        return strdup(buf);
    }
}

可以看到,这里有一个宏 LINENOISE_MAX_LINE,值是 4096,因此在 redis-cli 的交互模式中,所有字符加起来最长是 4095(还有一个是 \0 结尾字符)。

直观的解决方案就是修改 4096 为更大的数字,比如 8192。修改完重新编辑即可,这样可以解决大部分问题。但是如果我们需要写入大于 8192 字符的数据呢,不可能一直修改这个宏定义,还是要利用上文件的能力。

注意,宏定义在 linenoise.c 文件中,需要重新编译 linenoise.c 文件,再编译 redis-cli.c 文件才可以。

2、文件数据支持转义

redis-cli 非交互模式的实现是函数 noninteractive,函数根据 stdinarg 参数有两个分支,一个是从 stdin 中读取最后一个参数;另一个是参数已经足够,直接执行相关 redis 命令即可。

比如命令:./redis-cli SET test `cat ~/tmp.txt`,文件 tmp 中的数据就是 argv 数组中的最后一个参数,我们需要针对 argv 数组做转义字符串的特殊处理。

static int noninteractive(int argc, char **argv) {
    if (config.stdinarg) {
        // 将最后一个stdin参数 添加到argv数组中
        argv = zrealloc(argv, (argc+1)*sizeof(char*));
        argv[argc] = readArgFromStdin();
        retval = issueCommand(argc+1, argv);
        
    } else {
        retval = issueCommand(argc, argv);
    }
    return retval;
}

为了避免影响 redis-cli 的原有功能,给 redis-cli 增加一个参数来实现此功能。实现思路:给 redis-cli 增加新参数 --input-file-escape,识别有此参数时,针对 argv 数组中的所有数据,进行转义处理。

第一步,增加 --input-file-escape 参数,全局 config 结构体增加对应变量:

static struct config {
    char *hostip;
    int hostport;
    // ...
    int input_file_escape;	// 新增变量
} config;

第二步,在 main 函数中给变量赋初值:

int main(int argc, char **argv) {
    int firstarg;

    config.hostip = sdsnew("127.0.0.1");
    config.hostport = 6379;
    config.input_file_escape = 0;	// 新变量赋初值
    // ...
}

第三步,解析参数,在函数 parseOptions 中增加对应参数的解析:

static int parseOptions(int argc, char **argv) {
    int i;
    for (i = 1; i < argc; i++) {
        if (!strcmp(argv[i],"-h") && !lastarg) {
            sdsfree(config.hostip);
            config.hostip = sdsnew(argv[++i]);
        } else if (!strcmp(argv[i],"-h") && lastarg) {
            usage();
        } else if (!strcmp(argv[i], "--input-file-escape")) {	// 新增变量解析
            config.input_file_escape = 1;
        }
        // ...
    }

    return i;
}

第四步,非交互处理函数 noninteractive 中,对输入的参数数组 argv 进行转义处理:

static int noninteractive(int argc, char **argv) {
    if (config.stdinarg) {
        // ...
        
    } else {
        // 下面使用的是 hiredis里的sds.c文件
        if (config.input_file_escape) {
            // 循环对参数内的\进行转义处理
            for (int i=0; i<argc; ++i) {
                sds arg = sdsempty();
                argv[i] = sdscatescape(arg,argv[i],strlen(argv[i]));
            }
        }

        retval = issueCommand(argc, argv);
    }
    return retval;
}

第五步,这里新增加了一个函数 sdscatescape(deps/hiredis/sds.c 文件中),用于对文件中的 \ 以及之后的字符进行转义相关处理,实现如下:

  • 函数参考 sdscatrepr 函数实现。
sds sdscatescape(sds s, const char *p, size_t len) {
    while(len--) {
        switch(*p) {
        case '\\':
            len -= 1;
            if (len <= 0)
                break;
            p++;

            switch (*p) {
            case '\\': s = sdscatlen(s,"\\",1); break;
            case '"': s = sdscatlen(s,"\"",1); break;
            case 'n': s = sdscatlen(s,"\n",1); break;
            case 'r': s = sdscatlen(s,"\r",1); break;
            case 't': s = sdscatlen(s,"\t",1); break;
            case 'a': s = sdscatlen(s,"\a",1); break;
            case 'b': s = sdscatlen(s,"\b",1); break;
            case 'x': 
                len -= 2;
                if (len <= 0)
                    break;
                // 16进制 后两位转化为16进制的一个字节
                if (is_hex_digit(*(p+1)) && is_hex_digit(*(p+2))) {
                    unsigned char byte;

                    byte = hex_digit_to_int(*(p+1))*16 + hex_digit_to_int(*(p+2));
                    s = sdscatlen(s,(char*)&byte,1);
                }
                p += 2;
                break;
            default:
                break;
            }

            break;
        default:
            s = sdscatprintf(s,"%c",*p);
            break;
        }
        p++;
    }
    return s;
}

至此,第二个方案代码就完成了,编译 redis-cli 后,直接执行即可,效果如下:

因为修改了 deps/hiredis/sds.c 文件,需要先编译 deps 目录下的文件,再编译 redis-cli。

[root@b8bd4b93c6cb src]# cat ~/tmp.txt
0123456789\test
[root@b8bd4b93c6cb src]# ./redis-cli SET test `cat ~/tmp.txt`
OK
[root@b8bd4b93c6cb src]# ./redis-cli GET test
"0123456789\\test"
[root@b8bd4b93c6cb src]# ./redis-cli --input-file-escape SET test `cat ~/tmp.txt`
OK
[root@b8bd4b93c6cb src]# ./redis-cli GET test
"0123456789\test"