iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Software Development

從零開始打造 Redis:以 Go 建立 Production Ready 應用程式系列 第 8

[Day 08] RESP(中):Redis 的通訊協定--BulkString 與 Array

  • 分享至 

  • xImage
  •  

BulkString 與 Array

BulkString

根據 RESP 的規格,存在兩種 BulkString:

Redis 直到 RESP3 才定義了 NULL 資料型態,因此在 RESP2 時期都是使用 NULL Bulk String 來借指 NULL。

BulkString 由以下四個部份組成:

  • 魔術字元:-
  • 長度:可以是正數或負數。負數代表 NULL String,正數代表下方後方資料欄位的長度
  • 資料:實際存放的資料
  • 結束符:\r\n

綜上所述,我們可以定義:

// file: ./pkg/resp/value.go
type BulkString struct {
	null bool
    data []byte
}

func (v BulkString) Marshal() []byte { 
 	/* 
 	 * Null Bulk String Format: 
 	 * |-------------------|--------|----------| 
 	 * | MAGIC_BULK_STRING | LENGTH | SENTINEL | 
 	 * |-------------------|--------|----------| 
 	 * |               '-' | "-1"   | "\r\n"   | 
 	 * |-------------------|--------|----------| 
 	 */ 
 	if v.null { 
 		return []byte("$-1\r\n") 
 	} 
  
 	/* 
 	 * Non Null Bulk String Format: 
 	 * |-------------------|--------|---------|----------| 
 	 * | MAGIC_BULK_STRING | Length | Content | SENTINEL | 
 	 * |-------------------|--------|---------|----------| 
 	 * |               '$' |    "5" | "hello" | "\r\n"   | 
 	 * |-------------------|--------|---------|----------| 
 	 */ 
 	var buf bytes.Buffer 
 	buf.WriteRune(MAGIC_BULK_STRING) 
 	buf.WriteString(strconv.Itoa(len(v.data))) 
 	buf.WriteString(SENTINEL) 
 	buf.Write(v.data) 
 	buf.WriteString(SENTINEL) 
 	return buf.Bytes() 
 } 

Array

RESP Array 可以視為零或多個 Value 組成的值,Array 裡面可以容納所有不同的 RESP 資料型態,包括 Array 本身(可以以此構造巢狀結構)。

舉例來說:

  • Array{ SimpleString{"Hello"}, SimpleString{"World"} }:由兩個 SimpleString 組合而成的 Array
  • Array{ BulkString{"Hello"}, SimpleString{"World"} }:由一個 BulkSTring 與一個 SimpleString 組合而成的 Array

Array 的組成如下:

  • 魔術字元:*
  • 陣列長度:可以是正數或負數。負數代表 Null Array,正數代表後方有幾組資料
  • 資料:將 RESP Value 經過 Marshal() 之後的值,視陣列長度決定有幾組
  • 結束符:\r\n

綜上所述,我們可以定義:

// file: ./pkg/resp/value.go
type Array struct {
 	null bool
    data []Value
}

func (v Array) Marshal() []byte {
	/*
	 * Null Array Format:
	 * |-------------|--------|----------|
	 * | MAGIC_ARRAY | LENGTH | SENTINEL |
	 * |-------------|--------|----------|
	 * |         '*' | "-1"   | "\r\n"   |
	 * |-------------|--------|----------|
	 */
	if v.null {
		return []byte("*-1\r\n")
	}

	/*
	 * Array Format:
	 * |-------------|--------|----------|-----------------------|
	 * | MAGIC_ARRAY | LENGTH | SENTINEL | Marshaled Element [0] |
	 * |-------------|--------|----------|-----------------------|
	 * |         '*' |    "1" | "\r\n"   | "$4\r\nPING\r\n"      |
	 * |-------------|--------|----------|-----------------------|
	 */
	var buf bytes.Buffer
	buf.WriteRune(MAGIC_ARRAY)
	buf.WriteString(strconv.Itoa(len(v.data)))
	buf.WriteString(SENTINEL)
	for _, v := range v.data {
		buf.Write(v.Marshal())
	}
	return buf.Bytes()
}

Redis 請求中的 resp.(Value)

當 redis-cli 傳送指令時,可以把它想像成傳送了幾個由 BulkString 組成的 Array:

  • Array{ BulkString{"PING"} }:一個 PING 指令
  • Array{ BulkString{"GET"}, BulkString{"bar"} }:一個 GET 指令,帶有 bar 參數

net.Conn 讀取 resp.(Array)

先前在 Day 06 實作的 server.(*simpleSrv).serve() 如下:

func (s *simpleSrv) serve(conn net.Conn) {
	defer conn.Close()

	rd := bufio.NewReader(conn)
	msg, err := rd.ReadString('\n')
	if err != nil {
		slog.Error("failed to read from conn:", slog.Any("error", err))
		return
	}

	if _, err := conn.Write([]byte("ACK: " + strings.ToUpper(msg))); err != nil {
		slog.Error("failed to write to conn:", slog.Any("error", err))
		return
	}
}

bufio.(*Reader).ReadString('\n') 會從 net.Conn 讀取資料,直到遇到 \nEOF 為止。這個做法在先前實作 ACK 服務時是完全可以接受的,但是在解析 Redis 指令的時候就會遇到一些問題:

  • 對於單純的 BulkString Array,它可能會由多組 \n 組成,例如 *1\r\n$4\r\nPING\r\n 就需要反覆解析 3 次才能得知意圖。
  • 對於 BulkString,可能在字串中含有 \n 字元,這些字元也一樣會被視為結束符並輸出到 msg 中,這是非預期行為,將會導致一些邏輯上的問題。

實作 resp.(*Reader)

因此,比較好的做法應該是為了 RESP 實作一個 resp.(*Reader),其本質上僅僅是一個 bufio.(*Reader) 的封裝:

// file: ./pkg/resp/io.go
type Reader struct {
	rd *bufio.Reader
}

func NewReader(rd io.Reader) *Reader {
	return &Reader{rd: bufio.NewReader(rd)}
}

func (r *Reader) Read() (Value, error) {
	t, err := r.rd.ReadByte()
	if err != nil {
		return nil, err
	}

	switch t {
    case MAGIC_ARRAY:
		return r.readArray()
	case MAGIC_BULK_STRING:
		return r.readBulkString()
	default:
		return nil, fmt.Errorf("%w: %c", ErrUnknownType, t)
	}
}

藉由 resp.(*Reader).Read(),我們預期可以從 net.Conn 中取得 RESP Value 或出錯的話會回傳 error

註:你或許已經注意到了,在 Read() 中只處理了 MAGIC_ARRAYMAGIC_BULK_STRING 兩種情況,這是因為解析 Redis 指令的功能暫時還不需要其它的資料型態。

readLine()readInt()

誠如先前所說,Redis 指令本質上是「由 BulkString 組成的 Array」(在 Redis 內部將其稱為 "multibulk"),而無論是 Array 還是 BulkString,都可以被寫為以下格式 {MAGIC}{LENGTH}\r\n{DATA}\r\n

因此,我們可以在 resp.(*Reader) 中先建立 readLine()readInt(),以便後續建立 readArray()readBulkString()

// file: ./pkg/resp/io.go
func (r *Reader) readLine() ([]byte, error) {
	var buf bytes.Buffer

	for {
		data, err := r.rd.ReadBytes('\n')
		if err != nil {
			return nil, err
		}

		buf.Write(data)
        
        // 在這裡多判斷倒數第二個字元是不是 \r,藉此確定是不是 SENTINEL
		if data[len(data)-2] == '\r' { 
			return buf.Bytes()[:buf.Len()-2], nil
		}
	}
}

func (r *Reader) readInt() (int, error) {
	line, err := r.readLine()
	if err != nil {
		return 0, err
	}

	return strconv.Atoi(string(line))
}

readBulkString()

BulkString 被設計為是一個 Binary Safe 字串:無論字串中出現什麼內容,都不影響該字串在已被規定的長度下的表現。(換句話說,我們不能夠將 BulkString 交給 readLine() 去解析,因為要是 BulkString 中出現 \r\n 就會發生預期外的結果)

補充:C 語言的字串其本質上是一個字元陣列,並且以 \0(Null Byte)作為結束符,這就不是一個 binary safe 的字串,而歷史上也有許多資訊安全漏洞是因為這個設計而導致。

// file: ./pkg/resp/io.go

func (r *Reader) readBulkString() (BulkString, error) {
	sz, err := r.readInt()
	if err != nil {
		return BulkString{}, err
	}
    
    // 當讀出來的 sz < 0 時,表示它是一個 Null BulkString
	if sz < 0 { 
		return BulkString{null: true}, nil
	}

	// 根據 sz 大小,初始化一個 buf 空間
	buf := make([]byte, sz)
	// 利用 io.ReadFull 一次性讀完整個 buf 的空間
	if _, err := io.ReadFull(r.rd, buf); err != nil {
		return BulkString{}, err
	}

	// 已經獲得所需的資料,把最後一個 \r\n 丟棄即可
	r.rd.ReadByte()
	r.rd.ReadByte()

	return BulkString{data: buf}, nil
}

readArray()

RESP Array 可以被想像成「許多 RESP 值的集合」,因此我們可遞迴地讀取:

// file: ./pkg/resp/io.go

func (r *Reader) readArray() (Array, error) {
	sz, err := r.readInt()
	if err != nil {
		return Array{}, err
	}
    
	// 當讀出來的 sz < 0 時,表示它是一個 Null Array
	if sz < 0 {
		return Array{null: true}, nil
	}

	arr := Array{}
	arr.data = make([]Value, 0, sz)

	for range sz {
		// 遞迴性地呼叫 r.Read(),用於解析每一個 Array 中的值
		v, err := r.Read()
		if err != nil {
			return arr, err
		}

		arr.data = append(arr.data, v)
	}

	return arr, nil
}

將讀取到的值視為 resp.(Array)

修改 server.(*simpleSrv).serve() 的實作方式,改為採用 resp.(*Reader)

// file: ./internal/server/server.go
func (s *simpleSrv) serve(conn net.Conn) {
	defer conn.Close()

	rd := resp.NewReader(conn)
	cmd, err := rd.Read()
	if err != nil {
		slog.Error("failed to read from conn:", slog.Any("error", err))
		return
	}

	slog.Info("Read RESP command:", slog.Any("command", cmd))

	if _, err := conn.Write(resp.SimpleString("OK").Marshal()); err != nil {
		slog.Error("failed to write to conn:", slog.Any("error", err))
		return
	}
}

上一篇
[Day 07] RESP2(上):Redis 的通訊協定--資料型態與值
下一篇
[Day 09] RESP(下):Redis 的通訊協定--指令
系列文
從零開始打造 Redis:以 Go 建立 Production Ready 應用程式11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言