灌水了 11 天的文,我們終於可以進到整個 Redis 最核心的功能:鍵值存儲。
在實際設計 GET 與 SET 時,我們應該要先決定資料如何被儲存。
repo.(Storage) 介面建立一個 repo.(Storage) 介面,並提供所需的函式:
// file: ./internal/repo/storage.go
type Storage interface {
Set(ctx context.Context, k, v string) error
Get(ctx context.Context, k string) (string, error)
}
雖然目前 context.Context 還沒有作用,但是我們可以預期這兩個函式將會被 goroutine 多次調用,在這個地方留下 context.Context 是個良好的開發習慣。
repo.(*mapStorage) 實作在不考慮資料持久化的前提之下,其實 Go 早就內建了一個鍵值資料型態:map。
註:事實上,Go 在 1.24 之後 map 底層用了 Swiss Table,這是一個經過 SIMD 及分支預測最佳化的資料結構,並且有著非常優異的效能足以應付絕大多數場景,可以參閱 Faster Go maps with Swiss Tables 這篇文章。
建立 repo.(*mapStorage):
// file: ./internal/repo/storage.go
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
}
Go 的 map 並不是 Concurrency Safe(並發安全) 的資料結構,如果有兩個 goroutine 同時對 map 進行操作會觸發 panic,因此我們必須使用 sync.RWMutex 在存取時鎖定資源。
repo.(*mapStorage)建立 repo.NewStorage(),它可以初始化相關資源:
// file: ./internal/repo/storage.go
func NewStorage() Storage {
s := mapStorage{}
s.storage = make(map[string]string)
return &s
}
然後將 repo.Storage 作為 cmd.NewCommands() 的參數:
// file: ./internal/service/cmd/cmd.go
func NewCommands(storage repo.Storage) []Command {
return []Command{
&PING{},
}
}
file: ./internal/service/cmd/get.go
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
}
原始的 Redis SET 指令其實非常繁瑣,它具有各種不同的判斷功能。我們此處先不引入這些功能,避免過高的複雜度,單純專注在功能本身。
file: ./internal/service/cmd/set.go
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
}
最後將這兩個指令都注入 cmd.NewCommands() 中即可
// file: ./internal/service/cmd/cmd.go
func NewCommands(storage repo.Storage) []Command {
return []Command{
&Ping{},
NewSet(storage),
NewGet(storage),
}
}