Olivine:以 Go 建構與 Redis 相容的服務 · 章節目錄

第 3 章

Day 3、4:RESP2 Value 與 Reader

RESP:Redis Serialization Protocol

溝通需要共同的語言。要和 Redis 溝通,就要使用為它設計的 RESP。

RESP 有 RESP2 與 RESP3 兩個版本。這一章先實作較簡單的 RESP2。

注意:RESP3 大致上是 RESP2 的超集,自 Redis 6.0 起受到支援。

Redis 官方提供 Redis serialization protocol specification,說明 RESP 的設計原因與格式。

RESP Value

Magic Byte

RESP 透過 magic byte 辨識資料型別。RESP2 有五種型別:

  • Simple String:+
  • Simple Error:-
  • Integer::
  • Bulk String:$
  • Array:*

在 pkg/resp/value.go 定義常數:

https://github.com/jr-dragon/olivine/blob/

// file: pkg/resp/value.go
package resp

const(
	MAGIC_SIMPLE_STRING = '+'
	MAGIC_SIMPLE_ERROR  = '-'
	MAGIC_INTEGER       = ':'
	MAGIC_BULK_STRING   = '$'
	MAGIC_ARRAY         = '*'
)

注意:pkg 下的 package 用來提供其他開發者引用。可參考 golang-standards/project-layout。這不是 Go 官方標準,但能作為中大型專案組織方式的參考。

Value interface

Build Redis from scratch 使用下列 Value{}:

type Value struct {
	typ   string
	str   string
	num   int
	bulk  string
	array []Value
}

這很簡單,但我偏好為所有 RESP 型別定義共同的 Value interface:

type Value interface {
	Marshal() []byte
}

Marshal() 的命名受到 json.Marshal() 啟發,回傳要寫入連線、作為回應的內容。

Simple String

先建立實作 Value interface 的 SimpleString 型別。

type SimpleString []byte

func (v SimpleString) Marshal() []byte {
	/*
	 * Simple String Format:
	 * |---------------------|---------|----------|
	 * | MAGIC_SIMPLE_STRING | Content | SENTINEL |
	 * |---------------------|---------|----------|
	 * |                  '+'| "hello" | "\r\n"   |
	 * |------------------------------------------|
	 */
	var buf bytes.Buffer

	buf.WriteRune(MAGIC_SIMPLE_STRING)
	buf.Write(v)
	buf.WriteString(SENTINEL)

	return buf.Bytes()
}

寫入 net.Conn

internal/server/server.go 原本以 conn.Write([]byte("ACK: " + strings.ToUpper(msg))) 寫入回應。

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
	}

	time.Sleep(time.Second * 3)

	if _, err := conn.Write([]byte("ACK: " + strings.ToUpper(msg))); err != nil {
		slog.Error("failed to write to conn:", slog.Any("error", err))
		return
	}
}
-	if _, err := conn.Write([]byte("ACK: " + strings.ToUpper(msg))); err != nil {
+	if _, err := conn.Write(resp.SimpleString("OK").Marshal()); err != nil {
func (s *simpleSrv) serve(conn net.Conn) {
	defer conn.Close()

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

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

使用 redis-cli 手動測試

Redis 提供許多工具,其中 redis-cli 能以 RESP 傳送請求、處理回應,適合檢查服務是否能與 Redis client 溝通。

$ redis-cli -p 16879 ping
OK

Redis Command:Array 與 BulkString

請求中的 Redis Command 是由 Bulk Strings 組成的 RESP Array。

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

BulkString Value

RESP 區分下列兩種 Bulk String 表示方式:

RESP3 才定義獨立的 Null 型別,因此這裡以 Null Bulk String 表示空值。

// file: pkg/resp/value.go
const (
	MAGIC_SIMPLE_STRING = '+'
	MAGIC_SIMPLE_ERROR  = '-'
	MAGIC_INTEGER       = ':'
	MAGIC_BULK_STRING   = '$'
	MAGIC_ARRAY         = '*'
+	SENTINEL            = "\r\n"
)
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 Value

和 Bulk String 類似,RESP2 也定義了 Null Array。

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

從 net.Conn 讀取

目前使用 *bufio.Reader 處理連線,但它不是專為 RESP 設計的。

考慮以下 reader:

rd := bufio.NewReader(conn)
for {
	msg, _, err := rd.ReadLine()
	if err != nil {
		fmt.Printf("err = %+v\n", err)
		break
	} else {
		fmt.Printf("msg = %s\n", msg)
	}
}

傳入 *1\r\n$4\r\nPING\r\n 這個 Redis Command 時,必須分多次解析 msg,並不方便。

示範:Go Playground。

因此,我在 pkg/resp/io.go 建立 *resp.Reader:

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)
	}
}

readLine() 與 readInt()

Redis Command 是 Bulk Strings 的 Array,在 Redis 內部也稱為 multibulk。這裡以 {MAGIC}{LENGTH}\r\n{DATA}\r\n 概括 Array 與 Bulk String 的格式。

在實作 readArray()、readBulkString() 前,先建立 readLine() 與 readInt():

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

Bulk String 是 binary-safe 字串,讀取時根據 header 指定的長度取得資料。

注意:C-style 字串以 null byte \0 結尾,因此無法直接表示任意位置都可能包含 null byte 的資料。

func (r *Reader) readBulkString() (BulkString, error) {
	sz, err := r.readInt()
	if err != nil {
		return BulkString{}, err
	}
	if sz < 0 { // Ref: https://redis.io/docs/latest/develop/reference/protocol-spec/#null-bulk-strings
		return BulkString{null: true}, nil
	}

	buf := make([]byte, sz)
	if _, err := io.ReadFull(r.rd, buf); err != nil {
		return BulkString{}, err
	}

	// Drop tailing "\r\n" from reader.
	r.rd.ReadByte()
	r.rd.ReadByte()

	return BulkString{data: buf}, nil
}

readArray()

RESP Array 可以視為 RESP values 的集合,逐一遞迴讀取其中的值。

func (r *Reader) readArray() (Array, error) {
	sz, err := r.readInt()
	if err != nil {
		return Array{}, err
	}
	if sz < 0 { // Ref: https://redis.io/docs/latest/develop/reference/protocol-spec/#null-arrays
		return Array{null: true}, nil
	}

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

	for range sz {
		v, err := r.Read()
		if err != nil {
			return arr, err
		}

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

	return arr, nil
}

處理 Redis Commands

internal/server/server.go 原本使用 msg, err := rd.ReadString('\n'),現在改用 *resp.Reader。

	rd := resp.NewReader(conn)
-	msg, err := rd.ReadBytes('\n')
+	cmd, err := rd.Read()
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
	}
}