String 指令跑通後,今天換把 List、Hash、Set 和 ZSet 這四個比較麻煩的結構接到 code/command/commands.go。
因為這些指令通常帶很多參數,回覆格式也多是陣列,照著 RESP 規範封裝起來會比較費工。
List 命令包含推入(LPUSH, RPUSH)、彈出(LPOP, RPOP)與範圍讀取(LRANGE)。
推入方法支援多個 value 同時推入,回傳操作後 List 的長度。彈出若為空回傳 Null Bulk String:
func lpushCommand(dbEngine *db.DB, client *Client, args [][]byte) resp.Value {
if len(args) < 2 {
return resp.NewError("ERR wrong number of arguments for 'lpush' command")
}
key := string(args[0])
vals := args[1:]
newLen, err := dbEngine.LPush(key, vals...)
if err != nil {
return resp.NewError(err.Error())
}
return resp.NewInteger(newLen)
}
LRANGELRANGE 回傳的是 RESP Array,所以底層拿到的 [][]byte 要一個一個包成 Value:
func lrangeCommand(dbEngine *db.DB, client *Client, args [][]byte) resp.Value {
// ... 參數個數與 strconv 轉換 ...
res, err := dbEngine.LRange(key, start, stop)
if err != nil {
return resp.NewError(err.Error())
}
respVals := make([]resp.Value, len(res))
for i, val := range res {
respVals[i] = resp.NewBulkString(val)
}
return resp.NewArray(respVals) // 封裝為 RESP Array 回傳
}
Hash 包含欄位的設定、讀取與刪除。
HSET:新欄位回傳 1,更新已存在欄位回傳 0。HGET:回傳欄位內容,若不存在回傳 Null Bulk String。HDEL:傳入一或多個 field 進行刪除,回傳成功刪除的個數。func hsetCommand(dbEngine *db.DB, client *Client, args [][]byte) resp.Value {
if len(args) != 3 {
return resp.NewError("ERR wrong number of arguments for 'hset' command")
}
key := string(args[0])
field := string(args[1])
val := args[2]
res, err := dbEngine.HSet(key, field, val)
if err != nil { return resp.NewError(err.Error()) }
return resp.NewInteger(res)
}
Set 包含無序集合的添加(SADD)、刪除(SREM)與獲取所有成員(SMEMBERS):
func smembersCommand(dbEngine *db.DB, client *Client, args [][]byte) resp.Value {
if len(args) != 1 {
return resp.NewError("ERR wrong number of arguments for 'smembers' command")
}
key := string(args[0])
members, err := dbEngine.SMembers(key)
if err != nil { return resp.NewError(err.Error()) }
respVals := make([]resp.Value, len(members))
for i, m := range members {
respVals[i] = resp.NewBulkString([]byte(m))
}
return resp.NewArray(respVals)
}
ZSet 主要實作 ZADD 與 ZRANGE。
ZRANGE (支援 WITHSCORES 參數)實作 ZRANGE 的 WITHSCORES 時超級卡,因為 RESP 規範裡要把 Member 跟 Score 平攤在同一個 Array 裡交替出現。我一開始直接傳 map 回去,結果 redis-cli 根本看不懂,只好老老實實把它打平。
如果客戶端發送 ZRANGE key start stop WITHSCORES,我要交替排列回傳成員與分數(以字串 Bulk 表示):
func zrangeCommand(dbEngine *db.DB, client *Client, args [][]byte) resp.Value {
// ... 參數解析 ...
withScores := false
if len(args) >= 4 && strings.ToUpper(string(args[3])) == "WITHSCORES" {
withScores = true
}
res, err := dbEngine.ZRange(key, start, stop)
if err != nil { return resp.NewError(err.Error()) }
var respVals []resp.Value
if withScores {
respVals = make([]resp.Value, 0, len(res)*2)
for _, member := range res {
respVals = append(respVals, resp.NewBulkString([]byte(member.Member)))
respVals = append(respVals, resp.NewBulkString([]byte(strconv.FormatFloat(member.Score, 'f', -1, 64))))
}
} else {
respVals = make([]resp.Value, len(res))
for i, member := range res {
respVals[i] = resp.NewBulkString([]byte(member.Member))
}
}
return resp.NewArray(respVals)
}
先把 Server 跑起來:
go run ./code/main.go
順便測一下 Pipeline (管線化)。做法是一次用 printf 送多個 RESP 命令,讓它們一起進 TCP 連線,減少一問一答的網路等待時間:
# 一次送出三個命令:
# PING
# SET pipe_key 123
# GET pipe_key
printf "*1\r\n\$4\r\nPING\r\n*3\r\n\$3\r\nSET\r\n\$8\r\npipe_key\r\n\$3\r\n123\r\n*2\r\n\$3\r\nGET\r\n\$8\r\npipe_key\r\n" | nc localhost 6379
# 預期回覆:
# +PONG
# +OK
# $3
# 123
Server 會照順序處理這些命令,再把結果依序回來。批量操作時,這個差異會很明顯。
今天把幾個複雜資料結構的指令路由都串好了,Redis Clone 開始有比較完整的資料結構支援。說實在的,處理 RESP 陣列的巢狀結構時,一開始沒考慮好邊界條件,還不小心 panic 過幾次,還好有靠單元測試抓出來。
明天要寫 Redis 很有趣的特性:事務機制(MULTI, EXEC, DISCARD)。又要開始處理排隊邏輯了,大家明天見!