Olivine: Building a Redis-Compatible Service in Go. · 章節目錄

第 5 章

[Day 5] Redis Command Handler

Command{} and reader

“Redis Command” is a special data type not defined in RESP. Actually, it just RESP array with multiple bulk string.

I defined a Command{} in pkg/resp/cmd.go for converting from RSEP 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
}

Apply resp.ReadCommand in server

-	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 and Handler

In a small service, handler is a part of server, because server “handles” connections. However, when service scaling, the duty of server and handler are different.

  • Server: Listen a port, and accept connections.
  • Handler: Read requests from connections, do something and write response to connections.

Handler{} interface and implementation

I created internal/server/handler.go and add 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
}

Update 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

I used NewHandler() in NewServer() constructor, it makes Server{} depends on Handler{}, it’s a bad smell for this pattern, because no matter what I do, the Server{} requires Handler{} anyway.

There’s a solution: dependency injection, the NewServer() construct needs Handler{} as argument, we can free to choose other handler implementation when constructing Server{}.

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

And we need to update cmd/olivine/app.go:

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

Note: when the service scaling, we can use dependency injection library / tool to mangae dependencies, but it is no needed yet.

Multiple Commands in single connection

As I used redis-cli to check this server, I found a strange behavior.

$ 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

The s.serve() uses defer conn.Close() for each connection be processed in internal/server/server.go. We need to reconnect to server for every command.

It takes a lot of resources for multiple TCP handshakes, and redis-cli is also designed for multiple commands in single connection for a client.

Use a for loop for handling commands:

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

}

Error Handling

The ret, err := s.handler.ServeRESP(context.Background(), rd) will return and close connection immediately when error occurred, that’s no my expectation.

The err from handler.ServeRESP() could be 2 types:

  • Client Error: “Unknown command”, “Mismatch arguments”, …
  • Server Error: “Connection failed”, “Out of memory”, …

Connections can be closed when server error, but clients usually hope that keep connection alive if client error.

Defines the 2 types of error in 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 is an important design pattern, because it can make developers focus on single feature(redis command, in this service).

Add a factory in simpleHandler{}:

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
}

Define a function exec() for simpleHandler{}:

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

The *resp.Command.Command() is not defined now, we need to add it in pkg/resp/cmd.go:

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