iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
Software Development

手刻 Redis:用 Go 從零打造高效能高併發的記憶體資料庫系列 第 5

Day 05:實作 RESP Parser - 解析 Bulk Strings 與 Arrays,完成基礎的 Parser 單元測試

  • 分享至 

  • xImage
  •  

昨天把單行解析補完後,今天換處理 RESP 裡比較容易寫錯的兩個型別:Bulk Strings (二進位安全字串)Arrays (陣列)。這兩個一個要處理長度,一個要處理遞迴,剛好也順便把 parser 測試補起來。


解析 Bulk Strings ($)

1. 協定規範

Bulk Strings 用來表示長度可達 512MB 的任意二進位資料。其格式如下:

$<長度>\r\n<資料內容>\r\n

例如,傳送字串 foobar

$6\r\nfoobar\r\n

如果是空字串:

$0\r\n\r\n

如果是特殊值 Null (例如 Key 不存在):

$-1\r\n

2. 為什麼需要二進位安全(Binary Safe)?

二進位安全是指:字串中可以包含任意字元,包括 Null 字元 (\x00) 或換行符 (\n),而不會導致字串解析中斷或被截斷。

一般字串常常會被結尾符號或換行影響,但 RESP 先告訴你長度,所以 parser 可以用 io.ReadFull 直接讀指定byte 數。只要長度算對,內容裡有換行或 Null 字元也不會把解析切壞。

3. 程式碼實作

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 (*)

1. 協定規範

Arrays 的格式為:

*<元素個數>\r\n<元素1的RESP表示><元素2的RESP表示>...

例如,陣列中包含 foobar

*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n

如果是 Null Array:

*-1\r\n

2. 程式碼實作

由於陣列中的元素可以是任何 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 的時候,我還因為條件沒設好搞出個無窮迴圈,記憶體直接爆掉,超糗。


RESP 序列化(Marshal)的實作

除了從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,後面終於可以開始接真正的資料庫引擎。

明天進到記憶體資料庫引擎,總算要開始寫一點有資料庫感的東西了,大家明天見!


上一篇
Day 04:實作 RESP Parser - 解析 Simple Strings, Errors 與 Integers
系列文
手刻 Redis:用 Go 從零打造高效能高併發的記憶體資料庫5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言