從零開始寫redis客戶端(deerlet-redis-client)之路——第一個糾結很久的問題,restore引發的血案


引言

  

  正如之前的一篇博文,LZ最近正在從零開始寫一個redis的客戶端,主要目的是為了更加深入的了解redis,當然了,LZ也希望deerlet客戶端有一天能有一席之地。在寫的過程當中,LZ遇到了一個非常奇葩的問題。雖然現在看起來是一個非常低級的錯誤,但是在未打開這個謎底之前,着實讓LZ抓耳撓腮了一番,畢竟難者不會嘛。

  接下來,大家就來一起看下到底是什么問題吧。

  

restore命令的奇葩之處

  

  剛開始寫redis客戶端時,LZ只支持了一些常用的命令,比如get,set。初次寫這個客戶端時,LZ采取的辦法就是使用Socket和服務器進行TCP通信,傳輸的內容就是模擬在telnet端輸入的命令。比如在telnet端使用set和get命令時,是如下的方式。

  因此在寫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貢獻源碼。



免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM