转载

从零开始写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[0] == '*') {     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 == 0) {    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贡献源码。

正文到此结束
Loading...