Redispy 源碼學習(三) --- RESP協議實現--編碼

經過對RESP協議的閱讀,我們了解redis客戶端和服務端的通信方式。下面將根據resp協議使用python3實現其編碼,也就是將客戶端的查詢命令按照RESP協議編碼。

字符編碼

在處理resp編碼之前,有必要對字符的編碼做簡單的介紹。計算機給人感覺很強大,可是它們處理的數據的基本構成卻很簡單。任何計算機里的數據,無非都是一些二進制的0或者1。這些0和1當然不適合給人類閱讀,人類只寫自己認識的字符,例如hello world1 + 1之類的字符。計算機當然也會抗議,畢竟它們不懂。為了讓計算機能懂人類可讀的字符,就需要把這些字符轉換成0或1組成的二進制數據。這個轉換過程就是編碼,顧名思義,編碼的反方向就是解碼。

由于計算機是西方人搞出來的,美國人思來想去,拉丁字符才26個,亂七八糟的標點和美元百分好加起來也不過百多個。一個字節有8位,可以表示256種字符(2**8)。一個字節編碼符號綽綽有余。然后他們就依此指定了一個編碼表,即ASCII表。

可是沒多久,同樣是西方人的歐洲其他國家不干了,像法國德國這樣除了拉丁字符,還有類似拼音聲調的字符,ASCII的規定就不夠了。不僅這些字符,中國的漢字,日本文字,阿拉伯文字等,都無法用ASCII表示。既然世界文化這么多,就只能想一個完全之策來大一統。

Unicode應運而生,簡而言之就是使用2-4個字節來編碼。數量上肯定是足夠了,可是對于ASCII碼,無緣無故多出幾個字節來編碼顯然不合算,因此Unicde的一種實現utf-8就誕生了。utf-8兼容ascii方式,可以根據具體情況用1-4個字節來表示一個字符。例如一個漢字unicode編碼是一個長度 \u534eutf-8的編碼則是三個字符長度\xe5\x8d\x8e

除了utf-8編碼,中文世界里常見的是gbk方式編碼。gbk和utf-8一樣,也是一種編碼方式,不過只對中國的漢字和少數幾個民族文字兼容。范圍上比utf-8要小。

python字符編碼

提及編碼,python2經常出現UnicodeDecodeError錯誤,尤其是爬蟲的時候這個錯誤常被人詬病,很多人轉向python3。可是如果搞不清編碼與解碼的問題,python3也會出現UnicodeDecodeError異常。

python3中,所有字串都是unicode實現。也就是str類型。字串可以編碼成bytes類型,bytes類型可以解碼成字串。

>>> s = 'hello 世界'
>>> type(s)
<class 'str'>
>>> s.encode('utf-8')
b'hello \xe4\xb8\x96\xe7\x95\x8c'
>>> type(s.encode('utf-8'))
<class 'bytes'>
>>> b = b'hello 世界'
  File "<stdin>", line 1
SyntaxError: bytes can only contain ASCII literal characters.
>>> b = b'hello \xe4\xb8\x96\xe7\x95\x8c'
>>> b
b'hello \xe4\xb8\x96\xe7\x95\x8c'
>>> b.decode('utf-8')
'hello 世界'
>>> len(b)
12

對于python2而言,引號定義的字串是utf-8或者gbk的編碼(依賴系統)。使用u加字串定義的是unicode。因此py2也有encode和decode的方式。

無論py2還是py3,計算機內存處理的字串都是unicode,當寫入文件或者在網絡IO流中,都應該編碼成utf-8的格式(utf-8國際通用,就不必使用gbk了)。

解碼的時候就不能一概而論了。很多爬蟲的程序中,被爬的網站比較古老,使用了gbk的編碼。若不假思索的就以utf-8的方式decode,肯定會報錯。使用requests庫的時候,很少出現字符解碼錯誤,因為它內部有一個程序會先判斷目標字符的編碼,然后再針對性的解碼。因此我們寫程序的時候,解碼也應該先猜除對方編碼。至于怎么猜,可以學習requests的方式。

resp 字符編碼

說來那么多python的編碼,為得是下面RESP做鋪墊。根據redis.py 的源碼,編碼和解碼的方法掛載在Connection類的下面。因此我們的客戶端調用代碼如下:

 args = ('PING',)
 packed_command =  Connection().pack_command(*args)
 print(packed_command)

調用打印的結果為 [b'*1\r\n$4\r\nPING\r\n'],和預期的編碼一樣。

Connection 類

首先創建一個Connection類,我們需要初始化其編碼方式和編碼錯誤。

class Connection(object):
    def __init__(self, encoding='utf-8', encoding_errors='strict'):
        self.encoding = encoding
        self.encoding_errors = encoding_errors
    
    def pack_command(self, *args):
        pass

編碼命令

接下來實現pack_command 方法。

    def pack_command(self, *args):
        """將redis命令安裝redis的協議編碼,返回編碼后的數組,如果命令很大,返回的是編碼后chunk的數組"""
        output = []
        command = args[0]
        if ' ' in command:
            args = tuple([Token(s) for s in command.split(' ')]) + args[1:]
        else:
            args = (Token(command),) + args[1:]

        buff = SYM_EMPTY.join(
                (SYM_STAR, b(str(len(args))), SYM_CRLF))

        for arg in map(self.encode, args):
            # 數據量特別大的時候,分成部分小的chunk
            if len(buff) > 6000 or len(arg) > 6000:
                buff = SYM_EMPTY.join((buff, SYM_DOLLAR, b(str(len(arg))), SYM_CRLF))
                output.append(buff)
                output.append(arg)
                buff = SYM_CRLF
            else:
                buff = SYM_EMPTY.join((buff, SYM_DOLLAR, b(str(len(arg))), SYM_CRLF, arg, SYM_CRLF))

        output.append(buff)
        return output

該方法首先判斷了命令的方式,是單命令(PING)還是復合命令(CONFIG SET)。然后針對這兩種方式分別使用Token編碼。Token即命令的頭標簽。

然后使用SYM_EMPTY把字符頭標簽進行編碼。

def b(x):
    '''將`unicode`編碼成`bytes` 編碼格式位 `latin-1`'''
    return x.encode('latin-1') if not isinstance(x, bytes) else x

SYM_STAR = b('*')
SYM_DOLLAR = b('$')
SYM_CRLF = b('\r\n')
SYM_EMPTY = b('')

因為傳輸的字串應該是字節串(bytes)類型,并且幾個符號都是ascii符號,因此編碼成latin-1utf-8都是一樣的。

可以看見PING編碼的頭標簽為'*1\r\nCONFIG SET的oken為b'*4\r\n'

接下來就是一個迭代編碼除了token之外,編碼命令和參數。當buff不大的時候,就直接按照RESP協議串聯即可。即token + $ + 字節串長度+CRLF+參數+CRLF的方式

如果token和參數大于6000字節長度,就把編碼的命令組合拆分為小長度的chunk數組。

當然,在迭代命令和參數之前,需要將這些字串編碼成字節串。即map(self.encode, args)的功能,對應的encode方法如下:

    def encode(self, value):
        if isinstance(value, Token):
            return b(value.value)
        elif isinstance(value, bytes):
            return value
        elif isinstance(value, int):
            value = b(str(value))
        elif not isinstance(value, str):
            value = str(value)
        if isinstance(value, str):
            value = value.encode(self.encoding, self.encoding_errors)
        return value

此時可以看出頭標簽使用Token封裝,便于此時encode成bytes字節串,同時為python2提供了兼容的接口。python2只要重寫一個b函數即可。

總結

RESP編碼比較簡單,源于RESP的協議設計精巧。代碼實現的內容并不多。無非就是需要注意頭標簽token的編碼,和當命令參數特別長的時候,拆分字節串為chunk數組來發送數據。此外還需要注意,任何網絡傳輸的數據,都不能是直接的字符串,而是編碼成utf-8的字節串。

RESP的編碼并不復雜,更多挑戰在于如何解碼redis服務器的響應。在解析響應之前,我們應該創建redis的連接,將編碼的命令發送到redis服務器。

文中相關代碼

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容