从零开始写redis客户端(deerlet-redis-client)之路——第一个纠结很久的问题,restore引发的血案

引言

  

  正如之前的一篇博文,LZ最近正在从零开始写一个redis的客户端,主要目的是为了更加深入的了解redis,当然了,LZ也希望deerlet客户端有一天能有一席之地。在写的过程当中,LZ遇到了一个非常奇葩的问题。虽然现在看起来是一个非常低级的错误,但是在未打开这个谜底之前,着实让LZ抓耳挠腮了一番,毕竟难者不会嘛。

  接下来,大家就来一起看下到底是什么问题吧。

  

restore命令的奇葩之处

  

  刚开始写redis客户端时,LZ只支持了一些常用的命令,比如get,set。初次写这个客户端时,LZ采取的办法就是使用Socket和服务器进行TCP通信,传输的内容就是模拟在telnet端输入的命令。比如在telnet端使用set和get命令时,是如下的方式。

从零开始写redis客户端(deerlet-redis-client)之路——第一个纠结很久的问题,restore引发的血案

  因此在写deerlet时,LZ也是模仿的这种方式。比如在往服务器发送set命令时,LZ会采取以下的方式。

        if (command.name().indexOf(COMMAND_SEPARATOR) > 0) {
String[] commands = command.name().split(COMMAND_SEPARATOR);
outputStream.writeObject(commands[0]);
outputStream.writeSpace();
outputStream.writeObject(commands[1]);
} else {
outputStream.writeObject(command.name());
}
if (arguments != null) {
for (int i = 0; i < arguments.length; i++) {
outputStream.writeSpace();
outputStream.writeObject(arguments[i]);
}
}
outputStream.writeEnter();
outputStream.flush();

  这段代码的逻辑很简单,也是LZ目前deerlet客户端当中统一的发送命令的方法。这段代码的逻辑如下。

  1,如果命令不包含下划线(_),则直接写入命令。否则的话,将下划线分割的两个命令依次写入,中间加一个空格('\r'),比如script_flush命令。

  2,写入命令后,如果参数不为空,则循环写入参数,每个参数用空格隔开。

  3,结束时,写入一个回车符('\n')。

  所以,如果是set命令的话,假设我们设置someKey的值为value,那么这段代码写入的实际内容就是如下这个字节数组。 

['s', 'e', 't', '\r', 's', 'o', 'm', 'e', 'K', 'e', 'y', '\r', '\'', 'v', 'a', 'l', 'u', 'e', '\'', '\n']

  实践证明,这种方式支持很多redis的命令,比如get,set,flushall等等。这些命令,LZ的单元测试都完美通过。

  但是,问题来了,当LZ试图加入restore命令的支持时,竟然不管怎样都不行。这对于初次研究redis的LZ来说,真的是一个梦魇。因为尝试了各种办法,都无法让restore的单元测试通过,而且最要命的是,因为restore命令的参数中有字节数组,因此LZ无法在telnet端进行测试。

  

求助于“专业人士”

  

  LZ最后实在没办法了,只能求助于“专业人士”。只不过不同的是,这个“专业人士”并不是某一个人,而是jedis。是的,LZ去翻阅了jedis的源码。

  jedis作为redis比较知名的java客户端,对于LZ来说,肯定是有一定的参考价值的。只不过为了保证deerlet是纯净的,因此LZ一开始没有去翻阅jedis的源码,避免思维受到影响,最终把deerlet写的和jedis如出一辙。

  不过现在遇到了这么奇葩的问题,而且迟迟没有解决,LZ也就顾不上那么多了。在深入研究了jedis的源码之后,LZ发现jedis发送命令的核心代码是以下这段代码。

        try {
write(ASTERISK_BYTE);
writeIntCrLf(args.length + 1);
write(DOLLAR_BYTE);
writeIntCrLf(command.length);
write(command);
writeCrLf();
for (final byte[] arg : args) {
write(DOLLAR_BYTE);
writeIntCrLf(arg.length);
write(arg);
writeCrLf();
}
} catch (IOException e) {
throw new RuntimeException(e);
}

  同样的,假设还是set命令,同样的参数,jedis发送的数据是以下这种形式的。

*3
$3
set
$7
someKey
$5
value

  以上的数据,如果转换成字节数组的话,是如下的形式。

['*', '3', '\r', '\n', '$', '3', '\r', '\n', 's', 'e', 't', '\r', '\n', '$', '7', '\r', '\n', 's', 'o', 'm', 'e', 'K', 'e', 'y', '\r', '\n', '$', '5', '\r', '\n', 'v', 'a', 'l', 'u', 'e', '\r', '\n']

  LZ这里对以上的数据格式做一个简单的介绍。星号(*)后面的3代表的是有三个参数。第一个美元符号($)后面的3是代表的set的长度,以此类推,第四行的美元符号后面的7代表的是someKey的长度。jedis就是把这么一个字符串发送给了服务器,让LZ惊讶的是,使用这种方式去进行restore命令的操作,服务器竟然正确的返回了响应。

  为什么这么一大串看似规整但又看似杂乱的命令,redis服务器会正确的返回结果呢?

  

从问题的本质出发

  

  因为LZ实在想不通为什么redis会接受两种形式的命令,而且就算是redis接受,LZ也不明白为什么偏偏restore就不行。

  无奈之下,LZ只好从问题的本质出发。是的,LZ去翻阅了redis的源码。为此,LZ还专门在自己的Mac上面下载了xcode,学习了一番lldb,去尝试跟踪redis的服务器代码。

  经过一番折腾,LZ终于找到了根源。请看如下的代码,以下代码来自于networking.c。

void processInputBuffer(redisClient *c) {
server.current_client = c;
/* Keep processing while there is something in the input buffer */
while(sdslen(c->querybuf)) {
/* Return if clients are paused. */
if (!(c->flags & REDIS_SLAVE) && clientsArePaused()) break; /* Immediately abort if the client is in the middle of something. */
if (c->flags & REDIS_BLOCKED) break; /* REDIS_CLOSE_AFTER_REPLY closes the connection once the reply is
* written to the client. Make sure to not let the reply grow after
* this flag has been set (i.e. don't process more commands). */
if (c->flags & REDIS_CLOSE_AFTER_REPLY) break; /* Determine request type when unknown. */
if (!c->reqtype) {
if (c->querybuf[] == '*') {
c->reqtype = REDIS_REQ_MULTIBULK;
} else {
c->reqtype = REDIS_REQ_INLINE;
}
} if (c->reqtype == REDIS_REQ_INLINE) {
if (processInlineBuffer(c) != REDIS_OK) break;
} else if (c->reqtype == REDIS_REQ_MULTIBULK) {
if (processMultibulkBuffer(c) != REDIS_OK) break;
} else {
redisPanic("Unknown request type");
} /* Multibulk processing could see a <= 0 length. */
if (c->argc == ) {
resetClient(c);
} else {
/* Only reset the client when the command was executed. */
if (processCommand(c) == REDIS_OK)
resetClient(c);
}
}
server.current_client = NULL;
}

  请注意循环当中的一句注释“Determine request type when unknown”,处在它下面的if判断,判断了命令的开头是否是星号(*)开头,并根据判断的结果,赋予了相应的类型——inline和multibulk。接下来,程序会根据命令的类型,分别调用相应的处理方法processInlineBuffer和processMultibulkBuffer。

  知道这个以后,LZ去翻阅了redis的官方文档,找到这样一句话,是用来解释inline格式的。

Sometimes you have only telnet in your hands and you need to send a command to the Redis server. While the Redis protocol is simple 
to implement it is not ideal to use in interactive sessions, and redis-cli may not always be available. For this reason Redis also
accepts commands in a special way that is designed for humans, and is called the inline command format.

  这段话简单翻译过来就是:有时你可能只有telnet,并且你需要给redis服务器发送命令。redis的协议在交互式会话当中使用起来并不理想,而且redis-cli也不总是好用的。因此redis就专门为此设计了一套特殊的命令方式,称之为inline命令格式。

  总的来说,这下LZ总算是彻底明白了。inline协议,也就是deerlet客户端之前所使用的协议是redis为交互式会话提供的(比如telnet),主要目的是为了操作方便。如果要想做应用之间的交互,还是要使用multibulk协议,比如jedis在发送命令时,格式就是遵循multibulk协议的。如果大家想了解更多关于resp(即redis序列化协议)的内容,可以翻阅官方文档(地址:http://www.redis.io/topics/protocol),LZ这里就不再多做介绍了,只是起到一个抛砖引玉的作用。

水落石出

  

  知道了以上内容,就不难去测试为什么restore命令不能使用了。我们可以猜想出来,之前restore单元测试失败的原因大概是因为dump后的字节数组中包含了空格字符。为了确认我们的猜测是正确的,LZ将dump命令执行后的数组在程序中打印了出来,如下。

['', '	', 'T', 'e', 's', 't', 'V', 'a', 'l', 'u', 'e', '', '', '(', 'B', 'ᄁ', 'ヨ', 'ᅩ', 'ム', 'ᅧ', '!']

  可以看到,第二个字符是一个空格字符,因此在使用inline格式发送时,会导致redis服务器进行错误的解析,它会把一个参数当作两个参数去解析,最终导致参数的数量不符合命令要求。

  这里也能够看出来,inline协议的好处在于方便简单,但是坏处也很明显,就是在某些情况下会导致出错,比如当传输的参数内容当中包含空格时就会导致redis解析失败。

  

小结

  

  经过这一番问题的查找,可以看出,翻阅源码(如果有的话)是最有效直接的问题解决方式。LZ也建议大家,在遇到问题的时候,不要着急着百度,尝试去翻阅一下源码,这样能够帮助你对遇到的问题有一个比较深入的了解,以后再遇到的话,你将会游刃有余。

  好了,本文就到此结束,感谢大家的收看,如果deerlet再遇到问题的话,LZ再来与大家一起分享,也非常欢迎有志之士为deerlet贡献源码。

上一篇:【腾讯Bugly干货分享】职场中脱颖而出的成长秘诀


下一篇:【腾讯Bugly干货分享】聊一聊微信“小程序”