經過對RESP協議的閱讀,我們了解redis客戶端和服務端的通信方式。下面將根據resp協議使用python3實現其編碼,也就是將客戶端的查詢命令按照RESP協議編碼。
字符編碼
在處理resp編碼之前,有必要對字符的編碼做簡單的介紹。計算機給人感覺很強大,可是它們處理的數據的基本構成卻很簡單。任何計算機里的數據,無非都是一些二進制的0或者1。這些0和1當然不適合給人類閱讀,人類只寫自己認識的字符,例如hello world
, 1 + 1
之類的字符。計算機當然也會抗議,畢竟它們不懂。為了讓計算機能懂人類可讀的字符,就需要把這些字符轉換成0或1組成的二進制數據。這個轉換過程就是編碼,顧名思義,編碼的反方向就是解碼。
由于計算機是西方人搞出來的,美國人思來想去,拉丁字符才26個,亂七八糟的標點和美元百分好加起來也不過百多個。一個字節有8位,可以表示256種字符(2**8
)。一個字節編碼符號綽綽有余。然后他們就依此指定了一個編碼表,即ASCII表。
可是沒多久,同樣是西方人的歐洲其他國家不干了,像法國德國這樣除了拉丁字符,還有類似拼音聲調的字符,ASCII的規定就不夠了。不僅這些字符,中國的漢字,日本文字,阿拉伯文字等,都無法用ASCII表示。既然世界文化這么多,就只能想一個完全之策來大一統。
Unicode應運而生,簡而言之就是使用2-4個字節來編碼。數量上肯定是足夠了,可是對于ASCII碼,無緣無故多出幾個字節來編碼顯然不合算,因此Unicde的一種實現utf-8
就誕生了。utf-8
兼容ascii
方式,可以根據具體情況用1-4個字節來表示一個字符。例如一個漢字華
unicode編碼是一個長度 \u534e
,utf-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-1
和utf-8
都是一樣的。
可以看見PING
編碼的頭標簽為'*1\r\n
, CONFIG 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服務器。
文中相關代碼