昨天把單行解析補完後,今天換處理 RESP 裡比較容易寫錯的兩個型別:Bulk Strings (二進位安全字串) 與 Arrays (陣列)。這兩個一個要處理長度,一個要處理遞迴,剛好也順便把 parser 測試補起來。
$)Bulk Strings 用來表示長度可達 512MB 的任意二進位資料。其格式如下:
$<長度>\r\n<資料內容>\r\n
例如,傳送字串 foobar:
$6\r\nfoobar\r\n
如果是空字串:
$0\r\n\r\n
如果是特殊值 Null (例如 Key 不存在):
$-1\r\n
二進位安全是指:字串中可以包含任意字元,包括 Null 字元 (\x00) 或換行符 (\n),而不會導致字串解析中斷或被截斷。
一般字串常常會被結尾符號或換行影響,但 RESP 先告訴你長度,所以 parser 可以用 io.ReadFull 直接讀指定byte 數。只要長度算對,內容裡有換行或 Null 字元也不會把解析切壞。
func (r *Reader) readBulkString() (Value, error) {
// 1. 讀取長度前綴
lenVal, err := r.readInteger()
if err != nil {
return Value{}, err
}
// 2. 判斷是否為 Null Bulk String
if lenVal == -1 {
return NewNullBulkString(), nil
}
if lenVal < 0 {
return Value{}, errors.New("無效的 Bulk String 長度")
}
// 3. 根據長度分配緩衝區,並精確讀取對應長度的資料
buf := make([]byte, lenVal)
_, err = io.ReadFull(r.rd, buf)
if err != nil {
return Value{}, err
}
// 4. 精確讀取接下來的 \r\n (CRLF) 結尾,確保協定格式正確
crlf := make([]byte, 2)
_, err = io.ReadFull(r.rd, crlf)
if err != nil {
return Value{}, err
}
if crlf[0] != '\r' || crlf[1] != '\n' {
return Value{}, errors.New("Bulk String 缺少 CRLF 結尾")
}
return NewBulkString(buf), nil
}
*)Arrays 的格式為:
*<元素個數>\r\n<元素1的RESP表示><元素2的RESP表示>...
例如,陣列中包含 foo 和 bar:
*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n
如果是 Null Array:
*-1\r\n
由於陣列中的元素可以是任何 RESP 資料類型(甚至包含另一個 Array),所以必須採用遞迴解析。在讀取陣列大小後,循環調用主入口 ReadValue() 函數:
func (r *Reader) readArray() (Value, error) {
// 1. 讀取陣列元素數量
lenVal, err := r.readInteger()
if err != nil {
return Value{}, err
}
// 2. 判斷是否為 Null Array
if lenVal == -1 {
return NewNullArray(), nil
}
if lenVal < 0 {
return Value{}, errors.New("無效的 Array 長度")
}
// 3. 遞迴讀取每個元素
arr := make([]Value, lenVal)
for i := 0; i < lenVal; i++ {
val, err := r.ReadValue()
if err != nil {
return Value{}, err
}
arr[i] = val
}
return NewArray(arr), nil
}
這就是遞迴解析好用的地方。只要協定格式是對的,不管 Array 裡面再包幾層 Array,都可以一路拆成 Go 裡的 Value 結構。
寫遞迴解析 Array 的時候,我還因為條件沒設好搞出個無窮迴圈,記憶體直接爆掉,超糗。
除了從input stream中解析 RESP 外,伺服器還需要向客戶端回覆資料。因此,需要在 Value 結構上實作serialize 方法 Marshal,將 Go 記憶體中的 Value 轉換為符合 RESP 協定的byte 序列。
我在 code/resp/parser.go 中實作如下:
func (v Value) Marshal() []byte {
switch v.Type {
case TypeSimpleString:
return []byte("+" + v.Str + "\r\n")
case TypeError:
return []byte("-" + v.Str + "\r\n")
case TypeInteger:
return []byte(":" + strconv.Itoa(v.Num) + "\r\n")
case TypeBulkString:
if v.Bulk == nil {
return []byte("$-1\r\n")
}
return []byte("$" + strconv.Itoa(len(v.Bulk)) + "\r\n" + string(v.Bulk) + "\r\n")
case TypeArray:
if v.Array == nil {
return []byte("*-1\r\n")
}
res := []byte("*" + strconv.Itoa(len(v.Array)) + "\r\n")
for _, val := range v.Array {
res = append(res, val.Marshal()...)
}
return res
default:
return nil
}
}
Parser 最怕的是邊界條件沒顧到,所以我在 code/resp/parser_test.go 裡補了一批測試案例:
// 測試部分程式碼
func TestReadValue(t *testing.T) {
tests := []struct {
name string
input []byte
want Value
wantErr bool
}{
{
name: "Bulk String Normal",
input: []byte("$6\r\nfoobar\r\n"),
want: NewBulkString([]byte("foobar")),
wantErr: false,
},
{
name: "Array Nested",
input: []byte("*2\r\n*3\r\n:1\r\n:2\r\n:3\r\n*2\r\n+Foo\r\n-Bar\r\n"),
want: NewArray([]Value{
NewArray([]Value{NewInteger(1), NewInteger(2), NewInteger(3)}),
NewArray([]Value{NewSimpleString("Foo"), NewError("Bar")}),
}),
wantErr: false,
},
// ... 更多案例 ...
}
// ... 執行測試 ...
}
在 code/ 目錄下執行以下命令:
go test -v ./resp
輸出結果:
=== RUN TestReadValue
=== RUN TestReadValue/Simple_String
...
=== RUN TestReadValue/Array_Nested
--- PASS: TestReadValue (0.00s)
=== RUN TestMarshal
...
--- PASS: TestMarshal (0.00s)
PASS
ok redis-clone/resp 0.231s
測試都過了,至少目前這版 parser 對常見型別和巢狀 Array 的處理沒有明顯破洞。
今天我們主要是補上了 RESP 陣列的解析邏輯,為了確保一切正常,直接跑測試是最準的:
$ go test -v ./resp/...
# 預期輸出:
# === RUN TestParser_Array
# --- PASS: TestParser_Array (0.00s)
# PASS
# ok redis-clone/resp 0.123s
看到滿滿的 PASS,代表這版 RESP parser 已經可以處理大多數常見的指令陣列了。
前 5 天算是把網路層和 RESP parser 的地基打起來了。TCP Server 能收連線,parser 也能處理 Bulk String 和巢狀 Array,後面終於可以開始接真正的資料庫引擎。
明天進到記憶體資料庫引擎,總算要開始寫一點有資料庫感的東西了,大家明天見!