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

第 6 章

[Day 6] PING, SET and GET

The Command{} interface

As Handler{} is injected into Server{}, the Redis commands implementation should be injected into Handler{}.

Define a Command{} interface in internal/service/cmd/cmd.go:

package cmd

import (
	"context"
	"olivine/pkg/resp"
)

type Command interface {
	Command() string
	Exec(context.Context, *resp.Command) (resp.Value, error)
}

func NewCommands() []Command {
	return []Command{
		&Ping{},
	}
}

Add cmds []cmd.Commands as argument for NewHandler() in internal/server/handler.go:

func NewHandler(cmds []cmd.Command) Handler {
	factory := make(map[string]HandlerFunc)
	for _, cmd := range cmds {
		factory[cmd.Command()] = cmd.Exec
	}

	return &simpleHandler{
		factory: factory,
	}
}

Add NewCommands() into NewApp() in cmd/olivine/app.go:

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

Add Ping{} command

The syntax of PING command of Redis is:

PING [message]

The [message] is optional: if not set, return PONG; otherwise, return [message]:

(127.0.0.1:16879) > PING
PONG
(127.0.0.1:16879) > PING hello
"hello"

Create a new file in internal/service/cmd/ping.go:

package cmd

import (
	"context"
	"olivine/pkg/resp"
)

type Ping struct{}

func (c *Ping) Command() string {
	return "PING"
}

func (c *Ping) Exec(_ context.Context, cmd *resp.Command) (resp.Value, error) {
	args := cmd.Args()
	if len(args) == 0 {
		return resp.SimpleString("PONG"), nil
	}

	return args[0], nil
}

Note: remember to add Args() into *resp.Command.

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

Register Ping{} into NewCommands():

func NewCommands() []Command {
	return []Command{
		&Ping{},
	}
}

Add Get{} and Set{}

Before adding these commands, I need to decide how the data be stored.

The Storage{} interface

Define a Storage{} interface in internal/repo/storage.go:

package repo

import (
	"context"
	"errors"
	"sync"
)

var (
	ErrNotFound = errors.New("not found")
)

type Storage interface {
	Set(ctx context.Context, k, v string) error
	Get(ctx context.Context, k string) (string, error)
}

Note: honestly, the ctx context.Context haven’t in used yet, but it’s a good practice for using context.Context when chaning with goroutine.

The mapStorage{} implementation

There’s a built-in data type in Go for storage: Map.

Note: Go use Swiss Table as built-in map implementation, it’s a data structure which optimized with SIMD, branch prediction and more modern CPU features. Read more detail in Faster Go maps with Swiss Tables.

Create mapStorage{} to implement Storage{} interface:

type mapStorage struct {
	storage map[string]string
	mu      sync.RWMutex
}

func (s *mapStorage) Set(_ context.Context, k, v string) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	s.storage[k] = v
	return nil
}

func (s *mapStorage) Get(_ context.Context, k string) (v string, err error) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	var ok bool
	if v, ok = s.storage[k]; !ok {
		return "", ErrNotFound
	}

	return
}

Map is not a concurrency safe data type, it could panic when two or more goroutines access the same map, that’s why it needs to lock with sync.RWMutex when reading and writing.

The sync.RWMutex doesn’t block concurrency reading, it is useful in read-heavy scenario.

NewStorage() constructor

Add NewStorage() constructor for Storage{} interface:

func NewStorage() Storage {
	s := mapStorage{}
	s.storage = make(map[string]string)

	return &s
}

Add repo.Storage as argument in cmd.NewCommand():

// file: internal/service/cmd/cmd.go
-func NewCommands() []Command {
+func NewCommands(storage repo.Storage) []Command {
	return []Command{
		&Ping{},
	}
}

Add NewStorage() in NewApp():

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

Add Set{} and Get{}

It’s easy to add Set{} and Get{}:

package cmd

import (
	"context"
	"fmt"
	
	"olivine/internal/repo"
	"olivine/pkg/resp"
)

type Set struct {
	storage repo.Storage
}

func NewSet(storage repo.Storage) *Set {
	return &Set{storage: storage}
}

func (c *Set) Command() string {
	return "SET"
}

func (c *Set) Exec(ctx context.Context, cmd *resp.Command) (resp.Value, error) {
	args := cmd.Args()
	if len(args) != 2 {
		return nil, fmt.Errorf("%w: argument count mismatch: expect '%d' got '%d'", ErrValidation, len(args), 2)
	}

	k, v := args[0], args[1]
	if err := c.storage.Set(ctx, k.String(), v.String()); err != nil {
		return nil, fmt.Errorf("%w: %w", ErrStorage, err)
	}

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

import (
	"context"
	"errors"
	"fmt"
	
	"olivine/internal/repo"
	"olivine/pkg/resp"
)

type Get struct {
	storage repo.Storage
}

func NewGet(storage repo.Storage) *Get {
	return &Get{
		storage: storage,
	}
}

func (c *Get) Command() string {
	return "GET"
}

func (c *Get) Exec(ctx context.Context, cmd *resp.Command) (resp.Value, error) {
	args := cmd.Args()
	if len(args) != 1 {
		return nil, fmt.Errorf("%w: argument count mismatch: expect '%d' got '%d'", ErrValidation, len(args), 1)
	}

	k := args[0]
	v, err := c.storage.Get(ctx, k.String())
	if err != nil {
		if errors.Is(err, repo.ErrNotFound) {
			return resp.NewNullBulkString(), nil
		}
		return nil, fmt.Errorf("%w: %w", ErrStorage, err)
	}

	return resp.NewBulkString(v), nil
}

Add Set{} and Get{} into NewCommands():

func NewCommands(storage repo.Storage) []Command {
	return []Command{
		&Ping{},

		NewSet(storage),
		NewGet(storage),
	}
}