使用golang寫一個redis-cli
0. redis通信協議
redis的客戶端(redis-cli)和服務端(redis-server)的通信是建立在tcp連接之上, 兩者之間數據傳輸的編碼解碼方式就是所謂的redis通信協議。所以,只要我們的redis-cli實現了這個協議的解析和編碼,那麼我們就可以完成所有的redis操作。
redis 協議設計的非常易讀,也易於實現,關於具體的redis通信協議請參考:通信協議(protocol)。後面我們在實現這個協議的過程中也會簡單重複介紹一下具體實現
1. 建立tcp連接
redis客戶端和服務端的通信是建立tcp連接之上,所以第一步自然是先建立連接
package main
import (
"flag"
"log"
"net"
)
var host string
var port string
func init() {
flag.StringVar(&host, "h", "localhost", "hsot")
flag.StringVar(&port, "p", "6379", "port")
}
func main() {
flag.Parse()
tcpAddr := &net.TCPAddr{IP: net.ParseIP(host), Port: port}
conn, err := net.DialTCP("tcp", nil, tcpAddr)
if err != nil {
log.Println(err)
}
defer conn.Close()
// to be continue
}
後續我們發送和接受數據便都可以使用conn.Read()和conn.Write()來進行了
2. 發送請求
發送請求第一個第一個位元組是"*",中間是包含命令本身的參數個數,後面跟著"
" 。之後使用"$"加參數位元組數量並使用"
"結尾,然後緊跟參數內容同時也使用"
"結尾。如執行 SET key liangwt 客戶端發送的請求為"*3
$3
SET
$3
key
$7
liangwt
"
注意:
- 命令本身也作為協議的其中一個參數來發送
- 對應byte的十進位為 13 10
我們可以使用telnet測試下
wentao@bj:~/github.com/liangwt/redis-cli$ telnet 127.0.0.1 6379
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is "^]".
*3
$3
SET
$3
key
$7
liangwt
+OK
先暫時忽略服務端的回復,通過telnet我們可以看出請求協議非常簡單,所以對於請求協議的實現不做過多的介紹了,直接放代碼(如下使用基於字元串拼接,只是為了更直觀的演示,效率並不高,實際代碼中我們使用bytes.Buffer來實現)
func MultiBulkMarshal(args ...string) string {
var s string
s = "*"
s += strconv.Itoa(len(args))
s += "
"
// 命令所有參數
for _, v := range args {
s += "$"
s += strconv.Itoa(len(v))
s += "
"
s += v
s += "
"
}
return s
}
在實現了對命令和參數進行編碼之後,我們便可以通過conn.Write()把數據推送到服務端
func main() {
// ....
req := MultiBulkMarshal("SET", "key", "liangwt")
_, err = conn.Write([]byte(req))
if err != nil {
log.Fatal(err)
}
// to be continue
}
3. 獲取回復
我們首先實現通過tcp獲取服務端返回值,就是上面提到過的conn.Read()。
func main() {
// ....
p := make([]byte, 1024)
_, err = conn.Read(p)
if err != nil {
log.Fatal(err)
}
// to be continue
}
4. 解析回復
我們拿到p之後我們就可以解析返回值了,redis服務端的回復是分為幾種情況的
- 狀態回復
- 錯誤回復
- 整數回復
- 批量回復
- 多條批量回復
我們把前四種單獨看作一組,因為他們都是單一類型的返回值
我們把最後的多條批量回復看成單獨的一組,因為它是包含前面幾種類型的混合類型。而且你可以發現它和我們的請求協議是一樣的
也正是基於以上的考慮我們創建兩個函數來分別解析單一類型和混合類型,這樣在解析混合類型中的某一類型時就只需要調用單一類型解析的函數即可
在解析具體協議前我們先實現一個是讀取到
為止的函數
func ReadLine(p []byte) ([]byte, error) {
for i := 0; i < len(p); i++ {
if p[i] == "
" {
if p[i+1] != "
" {
return []byte{}, errors.New("format error")
}
return p[0:i], nil
}
}
return []byte{}, errors.New("format error")
}
第一種狀態回復:
狀態回復是一段以 "+" 開始, "
" 結尾的單行字元串。如 SET 命令成功的返回值:"+OK
"
所以我們判斷第一個字元是否等於 "+" 如果相等,則讀取到
func SingleUnMarshal(p []byte) ([]byte, int, error) {
var (
result []byte
err error
length int
)
switch p[0] {
case "+":
result, err = ReadLine(p[1:])
length = len(result) + 3
}
return result, length, err
}
註:我們在返回實際回復內容的同時也返回了整個回復的長度,方便後面解析多條批量回復時定位下一次的解析位置
第二種錯誤回復:
錯誤回復的第一個位元組是 "-", "
" 結尾的單行字元串。如執行 SET key缺少參數時返回值:"-ERR wrong number of arguments for "set" command
"
錯誤回復和狀態回復非常相似,解析方式也是一樣到。所以我們只需添加一個case即可
func SingleUnMarshal(p []byte) ([]byte, int, error) {
var (
result []byte
err error
length int
)
switch p[0] {
case "+", "-":
result, err = ReadLine(p[1:])
length = len(result) + 3
}
return result, length, err
}
第三種整數回復:
整數回復的第一個位元組是":",中間是字元串表示的整數,"
" 結尾的單行字元串。如執行LLEN mylist命令時返回 ":10
"
整數回復也和上面兩種是一樣的,只不過返回的是字元串表示的十進位整數
func SingleUnMarshal(p []byte) ([]byte, int, error) {
var (
result []byte
err error
length int
)
switch p[0] {
case "+", "-", ":":
result, err = ReadLine(p[1:])
length = len(result) + 3
}
return result, length, err
}
第四種批量回復:
批量回復的第一個位元組為 "$",接下來是字元串表示的整數,它表示實際回復的長度,之後跟著一個 "
",再後面跟著的是實際回複數據,最末尾是另一個 "
"。如GET key 命令的返回值:"$7
liangwt
"
所以批量回復解析的實現:
- 讀取第一行得到實際回復的長度
- 把字元串類型的長度轉換成對應十進位整數
- 從第二行開始位置往下讀對應長度
但是對於某些不存在的key,批量回復會將特殊值 -1 用作回復的長度值, 此時我們不需要繼續往下讀取實際回復。例如GET NOT_EXIST_KEY 返回值:"$-1", 所以我們需要對此特殊情況判斷,讓函數返回一個空對象(nil)而不是空值("")
func SingleUnMarshal(p []byte) ([]byte, int, error) {
// ....
case "$":
n, err := ReadLine(p[1:])
if err != nil {
return []byte{}, 0, err
}
l, err := strconv.Atoi(string(n))
if err != nil {
return []byte{}, 0, err
}
if l == -1 {
return nil, 0, nil
}
// +3 的原因 $
三個字元
result = p[len(n)+3 : len(n)+3+l]
length = len(n) + 5 + l
}
return result, length, err
}
思考:
為什麼redis要使用提前告知位元組數,然後往下讀取指定長度的方式,而不是直接讀取第二行到
為止?
答案很明顯:此方式可以讓redis讀取返回值時不受具體的返回內容影響,在按行讀取的情況下,無論使用任何分割符都有可能導致redis在解析具體內容時把內容中的分割符當作時結尾,導致解析錯誤。
思考一下這種情況:我們SET key "liang
wt" ,那麼當我們GET key時,服務端返回值為"$9
liang
wt
" 完全規避了value中的
影響
第五種多條批量回復:
多條批量回復是由多個回復組成的數組,它的第一個位元組為"*", 後跟一個字元串表示的整數值, 這個值記錄了多條批量回復所包含的回複數量, 再後面是一個"
"。如LRANGE mylist 0 -1的返回值:"*3
$1
3
$1
2
$1
1"。
所以多條批量回復解析的實現:
- 解析第一行數據獲得字元串類型的回複數量
- 把字元串類型的長度轉換成對應十進位整數
- 按照單條回復依次逐個解析,一共解析成上面得到的數量
在這裡我們用到了單條解析時返回的位元組長度length,通過這個長度我們可以很方便的知道下次單條解析的開始位置為上一次位置+length
在解析多條批量回復時需要注意兩點:
第一,多條批量回復也可以是空白的(empty)。例如執行LRANGE NOT_EXIST_KEY 0 -1 服務端返回值"*0
"。此時客戶端返回的應該空數組[][]byte
第二,多條批量回復也可以是無內容的(null multi bulk reply)。例如執行BLPOP key 1 服務端返回值"*-1
"。此時客戶端返回的應該是nil
func MultiUnMarsh(p []byte) ([][]byte, error) {
if p[0] != "*" {
return [][]byte{}, errors.New("format error")
}
n, err := ReadLine(p[1:])
if err != nil {
return [][]byte{}, err
}
l, err := strconv.Atoi(string(n))
if err != nil {
return [][]byte{}, err
}
// 多條批量回復也可以是空白的(empty)
if l == 0 {
return [][]byte{}, nil
}
// 無內容的多條批量回復(null multi bulk reply)也是存在的,
// 客戶端庫應該返回一個 null 對象, 而不是一個空數組。
if l == -1 {
return nil, nil
}
result := make([][]byte, l)
t := len(n) + 3
for i := 0; i < l; i++ {
ret, length, err := SingleUnMarshal(p[t:])
if err != nil {
return [][]byte{}, errors.New("format error")
}
result[i] = ret
t += length
}
return result, nil
}
5. 命令行模式
一個可用的redis-cli自然是一個互動式的,用戶輸入指令然後輸出返回值。在go中我們可以使用以下代碼即可獲得一個類似的互動式命令行
func main() {
// ....
for {
fmt.Printf("%s:%d>", host, port)
bio := bufio.NewReader(os.Stdin)
input, _, err := bio.ReadLine()
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s
", input)
}
}
我們運行以上代碼就可以實現
localhost:6379>set key liang
set key liang
localhost:6379>get key
get key
localhost:6379>
結合上我們的redis發送請求和解析請求即可完成整個redis-cli
func main() {
// ....
for {
fmt.Printf("%s:%d>", host, port)
// 獲取輸入命令和參數
bio := bufio.NewReader(os.Stdin)
input, err := bio.ReadString("
")
if err != nil {
log.Fatal(err)
}
fields := strings.Fields(input)
// 編碼發送請求
req := MultiBulkMarshal(fields...)
// 發送請求
_, err = conn.Write([]byte(req))
if err != nil {
log.Fatal(err)
}
// 讀取返回內容
p := make([]byte, 1024)
_, err = conn.Read(p)
if err != nil {
log.Fatal(err)
}
// 解析返回內容
if p[0] == "*" {
result, err := MultiUnMarsh(p)
} else {
result, _, err := SingleUnMarshal(p)
}
}
// ....
}
6. 總結
到目前為止我們的cli程序已經全部完成,但其實還有很多不完美地方。但核心的redis協議解析已經完成,使用這個解析我們能完成任何的cli與伺服器之間的交互
※一個兩年的程序員,面5家斬獲點我達,網易offer的面試總結
※spark中dataFrame的一些方法回顧
TAG:程序員小新人學習 |