Redis系列第一篇之SPEC協(xié)議

前言

Redis客戶端使用被稱為RESP(Redis序列化協(xié)議)的協(xié)議與Redis服務(wù)器進行通訊凰棉。雖然該協(xié)議是專門為Redis設(shè)計的讳侨,但它同樣可以被用于其他客戶端/服務(wù)器的軟件項目。
RESP是以下幾點的折中方案:

  • 實現(xiàn)起來簡單
  • 解析速度快
  • 可讀的

RESP可以序列化諸如整型、字符串和數(shù)組等不同的數(shù)據(jù)類型伶丐,還有一個特定的錯誤類型悼做。請求以字符串?dāng)?shù)組的形式由客戶端發(fā)送到Redis服務(wù)器,字符串?dāng)?shù)組表示需要執(zhí)行的命令哗魂。Redis用特定于命令的數(shù)據(jù)類型回復(fù)肛走。
RESP是二進制安全的,不需要處理從一個進程傳輸?shù)搅硪粋€進程的批量數(shù)據(jù)录别,因為它使用長度前綴來傳輸批量數(shù)據(jù)朽色。
注意: 這里描述的協(xié)議僅用于客戶端/服務(wù)器通信,Redis集群使用不同的二進制協(xié)議在節(jié)點之間交換信息组题。

網(wǎng)絡(luò)層

客戶端通過創(chuàng)建端口號為6379的TCP來連接Redis服務(wù)器葫男。
雖然RESP在技術(shù)上是非TCP特定的,但該協(xié)議僅用于Redis上下文的(或者等效的面向流的連接崔列,如Unix套接字)TCP連接梢褐。

請求-應(yīng)答模型

Redis接收由不同參數(shù)組成的命令。一旦命令被接收赵讯,將會被執(zhí)行并且發(fā)送一個回復(fù)給客戶端盈咳。
這可能是最簡單的模型,然而瘦癌,有兩個例外:

  • Redis支持管道操作猪贪,所以客戶端可能一次發(fā)送多個命令并且等待回復(fù)
  • 當(dāng)Redis客戶端訂閱了一個Pub/Sub頻道跷敬,協(xié)議語義改變?yōu)橐环N推送協(xié)議讯私。客戶端不再需要發(fā)送命令因為只要服務(wù)器收到新的消息西傀,將會自動發(fā)送新的消息到客戶端(對于客戶端訂閱的頻道)斤寇。

除了這兩種例外,Redis協(xié)議是一種簡單的請求-應(yīng)答協(xié)議拥褂。

RESP協(xié)議描述

RedisRESP協(xié)議在v1.2版本中介紹娘锁,但是到v2.0才變?yōu)榕c服務(wù)器通信的標準。
RESP協(xié)議支持以下數(shù)據(jù)類型: Simple Strings(簡單字符串)饺鹃,Errors(錯誤)莫秆,Integers(整型),Bulk Strings(批量字符串)以及Arrays(數(shù)組)悔详。
Redis通過以下方式將RESP用作請求-應(yīng)答協(xié)議:

  • 客戶端以Bulk String(批量字符串)組成的RESP數(shù)組發(fā)送命令到服務(wù)器镊屎。
  • 服務(wù)器根據(jù)命令以RESP數(shù)據(jù)類型之一回復(fù)客戶端

RESP中,第一個字節(jié)決定了數(shù)據(jù)類型:

  • +表示Simple Strings(簡單字符串)
  • -表示Errors(錯誤)
  • :表示Integers(整型)
  • $表示Bulk Strings(批量字符串)
  • *表示Arrays(數(shù)組)

RESP中茄螃,協(xié)議不同部分總是以\r\n(CRLF)結(jié)尾缝驳。
RESP使用特殊的組合表示空的Bulk Strings或者空的Arrays:$-1\r\n表示空的Bulk Strings,*-1\r\n表示空的Arrays,需要注意的是:$0\r\n*0\r\n分別表示有回復(fù)用狱,但長度為0运怖。

RESP Simple Strings(簡單字符串)

Simple Strings(簡單字符串)的編碼方式為:一個+號在最前面,后面跟著一個不能包含CR或者LF字符的字符串(即不允許換行符)夏伊,并且最后以CRLF(\r\n)結(jié)尾摇展。
Simple Strings(簡單字符串)以最小的開銷傳輸非二進制安全的字符串。例如:很多Redis命令執(zhí)行成功后的回復(fù)只是OK署海,RESP簡單字符串將以5個字節(jié)編碼:+OK\r\n
如果想要傳輸二進制安全的字符串吗购,請使用Bulk Strings替代。
當(dāng)Redis以簡單字符串回復(fù)時砸狞,客戶端庫應(yīng)該返回+號后面第一個字符后面的所有字符串(不包括CRLF字節(jié))捻勉。

RESP Errors(錯誤)

Redis有特定的錯誤類型,與Simple Strings相似刀森,不同的是第一個字符是減號-而不是加號+踱启,二者真正不同的是,客戶端將錯誤視為異常研底,而構(gòu)成Error類型的字符串就是錯誤消息本身埠偿。
錯誤類型的基本格式為:
-Error message\r\n
只有當(dāng)發(fā)生錯誤時才會回復(fù)錯誤,比如你想要在錯誤的數(shù)據(jù)類型上執(zhí)行命令榜晦,或者命令根本不存在冠蒋。客戶端收到Error回復(fù)時應(yīng)該拋出異常乾胶。
下面是錯誤回復(fù)的例子:

-ERR unknown command 'helloworld'
-WRONGTYPE Operation against a key holding the wrong kind of value

-號到后面第一個空格或者新行的第一個單詞表示返回的錯誤類型抖剿,這只是Redis使用的約定,而不是RESP錯誤格式的一部分识窿。
比如斩郎,ERR是一般錯誤,但是WRONGTYPE是一個更具體的錯誤喻频,暗示客戶端嘗試執(zhí)行應(yīng)對錯誤類型的操作缩宜。這被稱為錯誤前綴,是一種允許客戶端了解服務(wù)器返回的錯誤類型而無需檢查確切錯誤消息的方法甥温。
客戶端實現(xiàn)可能會針對不同的錯誤返回不同類型的異常锻煌,或者通過直接將錯誤名稱作為字符串提供給調(diào)用者來提供捕獲錯誤的通用方法。
但是不應(yīng)將此類功能視為至關(guān)重要姻蚓,因為它很少有用宋梧,并且有限的客戶端實現(xiàn)可能會簡單地返回通用錯誤條件,例如false

RESP Integers(整型)

這種類型只是一個以CRLF結(jié)尾的字符串史简,表示一個整數(shù)乃秀,前綴為:肛著,比如::0\r\n:1000\r\n
有很多返回整型的Redis命令跺讯,比如: INCR枢贿、LLEN以及LASTSAVE。返回的整型數(shù)據(jù)范圍為有符號的64位整數(shù)刀脏。
整型回復(fù)同樣可以用來表示true或者false局荚,比如EXISTS或者SISMEMBER將會返回1表示true,0表示false愈污。
其他命令比如SADD耀态、SREMSETNX如果被執(zhí)行了將會返回1暂雹,否則返回0首装。
其他返回整型的命令:SETNXDEL杭跪、 EXISTS仙逻、INCRINCRBY涧尿、DECR系奉、DECRBYDBSIZE姑廉、LASTSAVE缺亮、RENAMENXMOVE桥言、LLEN萌踱、SADDSREM限书、SISMEMBER虫蝶、SCARD章咧。

RESP Bulk Strings(批量字符串)

Bulk Strings被用來表示單個的最大長度512MB的二進制安全字符串倦西。
Bulk Strings編碼方式為:

  • $字符開頭,后面跟著字符串值的字節(jié)長度(長度前綴)赁严,以CRLF結(jié)尾扰柠。
  • 實際的字符串?dāng)?shù)據(jù)。
  • 最終的CRLF疼约。

所以卤档,字符串hello被編碼為:$5\r\nhello\r\n
一個空字符串被編碼為:$0\r\n\r\n
RESP Bulk Strings也可用特殊格式表示不存在(NULL),在這種格式中程剥,長度為-1劝枣,沒有數(shù)據(jù):$-1\r\n,這被稱作NULL Bulk String,當(dāng)服務(wù)器回復(fù)NULL Bulk String時舔腾,客戶端庫的API不應(yīng)該返回空的字符串溪胶,而是返回nil對象。

RESP Arrays(數(shù)組)

客戶端使用RESP Arrays發(fā)送命令到服務(wù)器稳诚。同樣哗脖,某些返回元素集合給客戶端的命令使用RESP數(shù)組作為回復(fù),比如:LRANGE命令扳还。RESP Arrays以下面的格式發(fā)送:

  • *開頭才避,后面跟著數(shù)組元素的數(shù)量,數(shù)量以十進制表示氨距,然后跟著CRLF桑逝。
  • Array每個元素附件的RESP類型。

所以俏让,空數(shù)組編碼為:*0\r\n
包含"hello"和"world"兩個元素的RESP數(shù)組被編碼為:*2\r\n$5\r\nhello\r\n$5\r\nworld\r\n
如你所見肢娘,*<count>CRLF前綴后面,組成數(shù)組的其他數(shù)據(jù)類型只是一個接一個的連接起來舆驶,比如一個由3個整型構(gòu)成的Array編碼結(jié)果為:*3\r\n:1\r\n:2\r\n:3\r\n
Array可以包含不同的數(shù)據(jù)類型橱健,比如一個有4個整型和一個批量字符串組成的Array編碼為:(為了直觀,以換行的形式展現(xiàn))

*5\r\n
:1\r\n
:2\r\n
:3\r\n
:4\r\n
$6\r\n
hello\r\n

第一行*5\r\n為了表示后面還有5個回復(fù)沙廉,然后再讀取后面的5個數(shù)組元素拘荡。
值為NULL的數(shù)組也存在(通常使用NULL Bulk String,由于歷史原因撬陵,NULL存在兩種格式)珊皿。比如BLPOP超時時將會返回一個長度為-1的NULL Array:*-1\r\n
在RESP中同樣存在嵌套的數(shù)組,比如兩個嵌套的數(shù)組編碼結(jié)果為:

*2\r\n
*3\r\n
:1\r\n
:2\r\n
:3\r\n
*2\r\n
+Hello\r\n
-World\r\n

上面的編碼結(jié)果包含兩個元素的數(shù)組巨税,第一個元素由(1蟋定,2,3)構(gòu)成的子數(shù)組草添,第二個元素由一個Bulk String(+Hello)和一個Error(-World)組成的數(shù)組驶兜。

Array中的Null元素

一個Array的單個元素可能為NULL。這在Redis回復(fù)中用來表示這些元素丟失而不是空字符串远寸。當(dāng)SORT命令使用GET pattern子命令并且key缺失時抄淑,將會發(fā)生這種情況。一個包含NULL元素的數(shù)組回復(fù)為:

*3\r\n
$5\r\n
hello\r\n
$-1\r\n
$5\r\n
world\r\n

上面的編碼解析結(jié)果為:["hello", nil, "world"]

發(fā)送命令到Redis服務(wù)器

可以根據(jù)上面幾部分的介紹來編寫Redis客戶端驰后,同時進一步了解客戶端和服務(wù)器之間的交互是如何工作的肆资。

  • 客戶端發(fā)送只由Bulk Strings組成的RESP Array到Redis服務(wù)器。
  • Redis以各種有效的RESP數(shù)據(jù)類型回復(fù)客戶端

所以灶芝,一種典型的交互場景可能如下:
為了獲取存儲在mylist中的列表的長度郑原,客戶端發(fā)送命令LLEN mylist到服務(wù)器唉韭,然后服務(wù)器回復(fù)客戶端一個整型回復(fù):

Client: *2\r\n$4\r\nLLEN\r\n$6\r\nmylist\r\n

Sserver: :48293\r\n

用Golang實現(xiàn)命令編碼與回復(fù)解析

import (
    "bufio"
    "bytes"
    "errors"
    "fmt"
    "net"
    "strconv"
)

// Reply load parsed reply from redis server
type Reply struct {
    array []*Reply // nested array
    value []byte   // SimpleString & Integer & BulkString
    err   error    // Error
}

type Client struct {
    c      net.Conn // tcp connection
    writer *bufio.Writer
    reader *bufio.Reader
}

func (c *Client) Send(cmd string, args ...interface{}) error {
    const crlf = "\r\n"
    var buf bytes.Buffer
    buf.WriteByte('*')                                         // Array標志
    buf.WriteString(strconv.FormatInt(int64(1+len(args)), 10)) // 寫入數(shù)組長度
    buf.WriteString(crlf)                                      // 寫入分隔符
    buf.WriteByte('$')                                         // 寫入命令部分
    buf.WriteString(strconv.FormatInt(int64(len(cmd)), 10))    // 寫入命令長度
    buf.WriteString(crlf)                                      // 寫入分隔符
    buf.WriteString(cmd)                                       // 寫入命令
    buf.WriteString(crlf)                                      // 寫入分隔符
    // 寫入各個參數(shù)
    for _, arg := range args {
        a := fmt.Sprint(arg)
        buf.WriteByte('$')
        buf.WriteString(strconv.FormatInt(int64(len(a)), 10))
        buf.WriteString(crlf)
        buf.WriteString(a)
        buf.WriteString(crlf)
    }
    if _, err := c.writer.Write(buf.Bytes()); err != nil {
        return err
    }
    return c.writer.Flush()
}

func (c *Client) Response() (interface{}, error) {
    line, err := c.ReadLine()
    if err != nil {
        return nil, err
    }
    if c.IsNilReply(line) {
        return nil, nil
    }
    switch line[0] {
    case '+', ':':
        return &Reply{value: line[1:]}, nil
    case '-':
        return &Reply{err: errors.New(string(line[1:]))}, nil
    case '$':
        bulk, err := c.ReadBulkString(line)
        if err != nil {
            return nil, err
        }
        return string(bulk), nil
    case '*':
        return c.ReadArray(line)
    default:
        return nil, fmt.Errorf("invalid redis reply type")
    }
}

func (c *Client) ReadLine() ([]byte, error) {
    line, err := c.reader.ReadSlice('\n')
    if err != nil {
        if err != bufio.ErrBufferFull {
            return nil, err
        }
        full := make([]byte, len(line))
        copy(full, line)

        line, err = c.reader.ReadBytes('\n')
        if err != nil {
            return nil, err
        }
        full = append(full, line...)
        line = full
    }
    if len(line) <= 2 || line[len(line)-2] != '\r' || line[len(line)-1] != '\n' {
        return nil, fmt.Errorf("read invalid reply: %q", line)
    }

    return line[:len(line)-2], nil // 去掉結(jié)尾的'\r\n'
}

func (c *Client) DataLen(data []byte) (int, error) {
    return strconv.Atoi(string(data))
}

func (c *Client) ReadBulkString(head []byte) ([]byte, error) {
    length, err := c.DataLen(head)
    if err != nil {
        return nil, err
    }
    buf := make([]byte, length+2)
    if _, err = c.reader.Read(buf); err != nil {
        return nil, err
    }
    return buf[:length], nil
}

func (c *Client) ReadArray(head []byte) (interface{}, error) {
    length, err := c.DataLen(head)
    if err != nil {
        return nil, err
    }
    // 處理空數(shù)組
    if length <= 0 {
        return &Reply{}, nil
    }
    var array = make([]interface{}, length)
    for i := 0; i < length; i++ {
        array[i], err = c.Response()
        if err != nil {
            return nil, err
        }
    }
    return array, nil
}

func (c *Client) IsNilReply(b []byte) bool {
    if len(b) == 3 && (b[0] == '$' || b[0] == '*') && b[1] == '-' && b[2] == '1' {
        return true
    }
    return false
}

參考資料

protocol-spec

原文連接

Redis系列第一篇之SPEC協(xié)議

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市犯犁,隨后出現(xiàn)的幾起案子纽哥,更是在濱河造成了極大的恐慌,老刑警劉巖栖秕,帶你破解...
    沈念sama閱讀 211,194評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件春塌,死亡現(xiàn)場離奇詭異,居然都是意外死亡簇捍,警方通過查閱死者的電腦和手機只壳,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,058評論 2 385
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來暑塑,“玉大人吼句,你說我怎么就攤上這事∈赂瘢” “怎么了惕艳?”我有些...
    開封第一講書人閱讀 156,780評論 0 346
  • 文/不壞的土叔 我叫張陵,是天一觀的道長驹愚。 經(jīng)常有香客問我远搪,道長,這世上最難降的妖魔是什么逢捺? 我笑而不...
    開封第一講書人閱讀 56,388評論 1 283
  • 正文 為了忘掉前任谁鳍,我火速辦了婚禮,結(jié)果婚禮上劫瞳,老公的妹妹穿的比我還像新娘倘潜。我一直安慰自己,他們只是感情好志于,可當(dāng)我...
    茶點故事閱讀 65,430評論 5 384
  • 文/花漫 我一把揭開白布涮因。 她就那樣靜靜地躺著,像睡著了一般伺绽。 火紅的嫁衣襯著肌膚如雪养泡。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,764評論 1 290
  • 那天憔恳,我揣著相機與錄音瓤荔,去河邊找鬼净蚤。 笑死钥组,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的今瀑。 我是一名探鬼主播程梦,決...
    沈念sama閱讀 38,907評論 3 406
  • 文/蒼蘭香墨 我猛地睜開眼点把,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了屿附?” 一聲冷哼從身側(cè)響起郎逃,我...
    開封第一講書人閱讀 37,679評論 0 266
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎挺份,沒想到半個月后褒翰,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,122評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡匀泊,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,459評論 2 325
  • 正文 我和宋清朗相戀三年优训,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片各聘。...
    茶點故事閱讀 38,605評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡揣非,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出躲因,到底是詐尸還是另有隱情早敬,我是刑警寧澤,帶...
    沈念sama閱讀 34,270評論 4 329
  • 正文 年R本政府宣布大脉,位于F島的核電站搞监,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏镰矿。R本人自食惡果不足惜腺逛,卻給世界環(huán)境...
    茶點故事閱讀 39,867評論 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望衡怀。 院中可真熱鬧棍矛,春花似錦、人聲如沸抛杨。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,734評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽怖现。三九已至茁帽,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間屈嗤,已是汗流浹背潘拨。 一陣腳步聲響...
    開封第一講書人閱讀 31,961評論 1 265
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留饶号,地道東北人铁追。 一個月前我還...
    沈念sama閱讀 46,297評論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像茫船,于是被迫代替她去往敵國和親琅束。 傳聞我的和親對象是個殘疾皇子扭屁,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,472評論 2 348

推薦閱讀更多精彩內(nèi)容