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

第 5 章

Day 5:Redis Command Handler

Command 與 reader

Redis Command 並不是 RESP 額外定義的資料型別,而是由多個 Bulk Strings 組成的 Array。

我在 pkg/resp/cmd.go 定義 Command,負責從 RESP Array 轉換:

package resp

import (
	"errors"
	"fmt"
)

var (
	ErrProtocol = errors.New("Protocol error")
)

type Command struct {
	raw  Value
	cmd  BulkString
	args []BulkString
}

func ReadCommand(rd *Reader) (*Command, error) {
	v, err := rd.Read()
	if err != nil {
		return nil, err
	}

	arr, ok := v.(Array)
	if !ok {
		return nil, fmt.Errorf("%w: expected array, got %T(%+v)", ErrProtocol, v, v)
	}
	if arr.null || len(arr.data) == 0 {
		return nil, fmt.Errorf("%w: empty array", ErrProtocol)
	}

	cmd, ok := arr.data[0].(BulkString)
	if !ok {
		return nil, fmt.Errorf("%w: command expected bulk string, got %T(%+v)", ErrProtocol, arr.data[0], arr.data[0])
	}

	args := make([]BulkString, 0, len(arr.data)-1)
	for i := range arr.data[1:] {
		arg, ok := arr.data[i+1].(BulkString)
		if !ok {
			return nil, fmt.Errorf("%w: argument [%d] expected bulk string, got %T(%+v)", ErrProtocol, i, arr.data[i+1], arr.data[i+1])
		}
		args = append(args, arg)
	}

	return &Command{
		raw:  v,
		cmd:  cmd,
		args: args,
	}, nil
}

在 server 使用 resp.ReadCommand

-	cmd, err := rd.Read()
+	cmd, err := resp.ReadCommand(rd)
func (s *simpleSrv) serve(conn net.Conn) {
	defer conn.Close()

	rd := resp.NewReader(conn)
	cmd, err := resp.ReadCommand(rd)
	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
	}
}

Server 與 Handler

小型服務常把 handler 放在 server 裡,因為 server 本來就需要處理連線。但隨著服務成長,兩者的職責應分開:

  • Server:監聽 port 並接受連線。
  • Handler:從連線讀取請求、執行操作,再寫入回應。

Handler interface 與實作

建立 internal/server/handler.go,加入 Handler interface:

// file: internal/server/handler.go
package server

type Handler interface {
	ServeRESP(context.Context, *resp.Reader) (resp.Value, error)
}

func NewHandler() Handler {
	return &simpleHandler{}
}

type simpleHandler struct {}

func (h *simpleHandler) ServeRESP(ctx context.Context, rd *resp.Reader) (resp.Value, error) {
	cmd, err := resp.ReadCommand(rd)
	if err != nil {
		return nil, err
	}

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

	return resp.SimpleString("OK"), nil
}

更新 internal/server/server.go:

// file: internal/server/server.go
func NewServer() Server {
	return &simpleSrv{
		handler: NewHandler(),
	}
}

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

	rd := resp.NewReader(conn)

	ret, err := s.handler.ServeRESP(context.Background(), rd)
	if err != nil {
		return
	}
	if ret == nil { // avoid panic
		ret = resp.SimpleString("OK")
	}

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

Dependency Injection

在 NewServer() 內直接呼叫 NewHandler(),會讓 server 綁定特定 handler 的建立方式。

透過 dependency injection,將 Handler 作為 NewServer() 的參數,就能在建立 server 時選擇不同的 handler 實作。

func NewServer(handler Handler) Server {
	return &simpleSrv{
		handler: handler,
	}
}

同時更新 cmd/olivine/app.go:

func NewApp() *App {
	return &App{
		srv: server.NewServer(server.NewHandler()),
	}
}

注意:服務規模成長後可以使用 dependency injection library 或工具管理依賴,目前還不需要。

在同一條連線處理多個指令

使用 redis-cli 檢查 server 時,我發現奇怪的行為:

$ redis-cli -p 16879
(127.0.0.1:16879) > ping
Error: Server closed the connection
(not connected) > ping
OK
(127.0.0.1:16879) > ping
Error: Server closed the connection

internal/server/server.go 的 s.serve() 使用 defer conn.Close(),每處理完一個指令就關閉連線,下一個指令必須重新連線。

反覆進行 TCP handshake 會消耗資源,redis-cli 也預期能在同一條連線傳送多個指令。

加入 for 迴圈處理:

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

	rd := resp.NewReader(conn)

	for {
		ret, err := s.handler.ServeRESP(context.Background(), rd)
		if err != nil {
			return
		}

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

}

錯誤處理

ret, err := s.handler.ServeRESP(context.Background(), rd) 一旦回傳錯誤,就立即返回並關閉連線,這不完全符合預期。

handler.ServeRESP() 的錯誤可以分成兩類:

  • Client Error:未知指令、參數不符等。
  • Server Error:連線失敗、記憶體不足等。

Server Error 可以關閉連線,但 Client Error 通常應保留連線,讓 client 繼續傳送指令。

在 internal/server/handler.go 定義這兩種錯誤:

var (
	ErrClient = errors.New("client error")
	ErrServer = errors.New("server error")
)
func (h *simpleHandler) ServeRESP(ctx context.Context, rd *resp.Reader) (resp.Value, error) {
	cmd, err := resp.ReadCommand(rd)
	if err != nil {
		if errors.Is(err, resp.ErrProtocol) {
			return resp.NewSimpleError(err), fmt.Errorf("%w: %w", ErrClient, err)
		} else {
			return nil, fmt.Errorf("%w: %w", ErrServer, err)
		}
	}

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

Command Factory

Factory Pattern 讓開發者能專注實作單一功能,在這個服務中就是各個 Redis Command。

在 simpleHandler 加入 factory:

type HandlerFunc func(context.Context, *resp.Command) (resp.Value, error)

func NewHandler() Handler {
	return &simpleHandler{
		factory: make(map[string]HandlerFunc),
	}
}

type simpleHandler struct {
	factory map[string]HandlerFunc
}

再定義 exec():

func (h *simpleHandler) ServeRESP(ctx context.Context, rd *resp.Reader) (resp.Value, error) {
	cmd, err := resp.ReadCommand(rd)
	if err != nil {
		if errors.Is(err, resp.ErrProtocol) {
			return resp.NewSimpleError(err), fmt.Errorf("%w: %w", ErrClient, err)
		} else {
			return nil, fmt.Errorf("%w: %w", ErrServer, err)
		}
	}

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

	return h.exec(ctx, cmd)
}

func (h *simpleHandler) exec(ctx context.Context, cmd *resp.Command) (resp.Value, error) {
	f, ok := h.factory[cmd.Command()]
	if !ok {
		err := fmt.Errorf("%w: unknown command: %s", ErrClient, cmd.Command())
		return resp.NewSimpleError(err), err
	}

	return f(ctx, cmd)
}

*resp.Command.Command() 尚未定義,因此也要加入 pkg/resp/cmd.go:

func (cmd *Command) Command() string {
	return strings.ToUpper(string(cmd.cmd.data))
}