Go語言之從0到1實現一個簡單的Redis連線池
前言
最近學習了一些Go語言開發相關內容,但是苦於手頭沒有可以練手的專案,學的時候理解不清楚,學過容易忘。
結合之前組內分享時學到的Redis相關知識,以及Redis Protocol文件,就想著自己造個輪子練練手。
這次我把目標放在了Redis client implemented with Go,使用原生Go語言和TCP實現一個簡單的Redis連線池和協議解析,以此來讓自己入門Go語言,並加深理解和記憶。(這樣做直接導致的後果是,最近寫JS時if語句總是忘帶括號QAQ)。
本文只能算是學習Go語言時的一個隨筆,並不是真正要造一個線上環境可用的Go-Redis庫~(︿( ̄︶ ̄)︿攤手)
順便安利以下自己做的一個跨平臺開源Redis管理軟體:AwesomeRedisManager官網AwesomeRedisManager原始碼
Redis協議主要參考這篇文件通訊協議(protocol),閱讀後瞭解到,Redis Protocol並沒有什麼複雜之處,主要是使用TCP來傳輸一些固定格式的字串資料達到傳送命令和解析Response資料的目的。
命令格式
根據文件瞭解到,Redis命令格式為(CR LF即\r\n):
*<引數數量N> CR LF
$<引數 1 的位元組數量> CR LF
<引數 1 的資料> CR LF
...
$<引數 N 的位元組數量> CR LF
<引數 N 的資料> CR LF
命令的每一行都使用CRLF結尾,在命令結構的開頭就宣告瞭命令的引數數量,每一條引數都帶有長度標記,方便服務端解析。
例如,傳送一個SET命令set name jeferwang
:
*3
$3
SET
$4
name
$9
jeferwang
響應格式
Redis的響應回覆資料主要分為五種型別:
- 狀態回覆:一行資料,使用
+
開頭(例如:OK、PONG等)
+OK\r\n
+PONG\r\n
- 錯誤回覆:一行資料,使用
-
開頭(Redis執行命令時產生的錯誤)
-ERR unknown command 'demo'\r\n
- 整數回覆:一行資料,使用
:
開頭(例如:llen返回的長度數值等)
:100\r\n
- 批量回復(可以理解為字串):兩行資料,使用
$
開頭,第一行為內容長度,第二行為具體內容
$5\r\n
abcde\r\n
特殊情況:$-1\r\n即為返回空資料,可以轉化為nil
- 多條批量回復:使用
*
開頭,第一行標識本次回覆包含多少條批量回復,後面每兩行為一個批量回復(lrange、hgetall等命令的返回資料)
*2\r\n
$5\r\n
ABCDE\r\n
$2\r\n
FG\r\n
更詳細的命令和回覆格式可以從Redis Protocol文件瞭解到,本位只介紹一些基本的開發中需要用到的內容
以下為部分程式碼,完整程式碼見GitHub:redis4go
實現流程
- 首先,我們根據官網文件瞭解到了Redis傳輸協議,即Redis使用TCP傳輸命令的格式和接收資料的格式,據此,我們可以使用Go實現對Redis協議的解析
- 接下來,在可以建立Redis連線並進行資料傳輸的前提下,實現一個連線池。
- 實現拼接Redis命令的方法,通過TCP傳送到RedisServer
- 讀取RedisResponse,實現解析資料的方法
模組結構分析
簡單分析Redis連線池的結構,可以先簡單規劃為5個部分:
- 結構體定義
entity.go
- Redis連線和呼叫
redis_conn.go
- Redis資料型別解析
data_type.go
- 連線池實現
pool.go
共劃分為上述四個部分
物件結構定義
為了實現連線池及Redis資料庫連線,我們需要如下結構:
- Redis伺服器配置
RedisConfig
:包含Host、Port等資訊 - Redis連線池配置
PoolConfig
:繼承RedisConfig
,包含PoolSize等資訊 - Redis連線池結構:包含連線佇列、連線池配置等資訊
- 單個Redis連線:包含TCP連線Handler、是否處於空閒標記位、當前使用的資料庫等資訊
package redis4go
import (
"net"
"sync"
)
type RedisConfig struct {
Host string // RedisServer主機地址
Port int // RedisServer主機埠
Password string // RedisServer需要的Auth驗證,不填則為空
}
// 連線池的配置資料
type PoolConfig struct {
RedisConfig
PoolSize int // 連線池的大小
}
// 連線池結構
type Pool struct {
Config PoolConfig // 建立連線池時的配置
Queue chan *RedisConn // 連線池
Store map[*RedisConn]bool // 所有的連線
mu sync.Mutex // 加鎖
}
// 單個Redis連線的結構
type RedisConn struct {
mu sync.Mutex // 加鎖
p *Pool // 所屬的連線池
IsRelease bool // 是否處於釋放狀態
IsClose bool // 是否已關閉
TcpConn *net.TCPConn // 建立起的到RedisServer的連線
DBIndex int // 當前連線正在使用第幾個Redis資料庫
}
type RedisResp struct {
rType byte // 回覆型別(+-:$*)
rData [][]byte // 從TCP連線中讀取的資料統一使用二維陣列返回
}
根據之前的規劃,定義好基本的結構之後,我們可以先實現一個簡單的Pool物件池
Redis連線
建立連線
首先我們需要實現一個建立Redis連線的方法
// 建立一個RedisConn物件
func createRedisConn(config RedisConfig) (*RedisConn, error) {
tcpAddr := &net.TCPAddr{IP: net.ParseIP(config.Host), Port: config.Port}
tcpConn, err := net.DialTCP("tcp", nil, tcpAddr)
if err != nil {
return nil, err
}
return &RedisConn{
IsRelease: true,
IsClose: false,
TcpConn: tcpConn,
DBIndex: 0,
}, nil
}
實現連線池
在Go語言中,我們可以使用一個chan
來很輕易地實現一個指定容量的佇列,來作為連線池使用,當池中沒有連線時,申請獲取連線時將會被阻塞,直到放入新的連線。
package redis4go
func CreatePool(config PoolConfig) (*Pool, error) {
pool := &Pool{
Config: config,
Queue: make(chan *RedisConn, config.PoolSize),
Store: make(map[*RedisConn]bool, config.PoolSize),
}
for i := 0; i < config.PoolSize; i++ {
redisConn, err := createRedisConn(config.RedisConfig)
if err != nil {
// todo 處理之前已經建立好的連結
return nil, err
}
redisConn.p = pool
pool.Queue <- redisConn
pool.Store[redisConn] = true
}
return pool, nil
}
// 獲取一個連線
func (pool *Pool) getConn() *RedisConn {
pool.mu.Lock()
// todo 超時機制
conn := <-pool.Queue
conn.IsRelease = false
pool.mu.Unlock()
return conn
}
// 關閉連線池
func (pool *Pool) Close() {
for conn := range pool.Store {
err := conn.Close()
if err != nil {
// todo 處理連線關閉的錯誤?
}
}
}
傳送命令&解析回覆資料
下面是向RedisServer傳送命令,以及讀取回複資料的簡單實現
func (conn *RedisConn) Call(params ...interface{}) (*RedisResp, error) {
reqData, err := mergeParams(params...)
if err != nil {
return nil, err
}
conn.Lock()
defer conn.Unlock()
_, err = conn.TcpConn.Write(reqData)
if err != nil {
return nil, err
}
resp, err := conn.getReply()
if err != nil {
return nil, err
}
if resp.rType == '-' {
return resp, resp.ParseError()
}
return resp, nil
}
func (conn *RedisConn) getReply() (*RedisResp, error) {
b := make([]byte, 1)
_, err := conn.TcpConn.Read(b)
if err != nil {
return nil, err
}
resp := new(RedisResp)
resp.rType = b[0]
switch b[0] {
case '+':
// 狀態回覆
fallthrough
case '-':
// 錯誤回覆
fallthrough
case ':':
// 整數回覆
singleResp := make([]byte, 1)
for {
_, err := conn.TcpConn.Read(b)
if err != nil {
return nil, err
}
if b[0] != '\r' && b[0] != '\n' {
singleResp = append(singleResp, b[0])
}
if b[0] == '\n' {
break
}
}
resp.rData = append(resp.rData, singleResp)
case '$':
buck, err := conn.readBuck()
if err != nil {
return nil, err
}
resp.rData = append(resp.rData, buck)
case '*':
// 條目數量
itemNum := 0
for {
_, err := conn.TcpConn.Read(b)
if err != nil {
return nil, err
}
if b[0] == '\r' {
continue
}
if b[0] == '\n' {
break
}
itemNum = itemNum*10 + int(b[0]-'0')
}
for i := 0; i < itemNum; i++ {
buck, err := conn.readBuck()
if err != nil {
return nil, err
}
resp.rData = append(resp.rData, buck)
}
default:
return nil, errors.New("錯誤的伺服器回覆")
}
return resp, nil
}
func (conn *RedisConn) readBuck() ([]byte, error) {
b := make([]byte, 1)
dataLen := 0
for {
_, err := conn.TcpConn.Read(b)
if err != nil {
return nil, err
}
if b[0] == '$' {
continue
}
if b[0] == '\r' {
break
}
dataLen = dataLen*10 + int(b[0]-'0')
}
bf := bytes.Buffer{}
for i := 0; i < dataLen+3; i++ {
_, err := conn.TcpConn.Read(b)
if err != nil {
return nil, err
}
bf.Write(b)
}
return bf.Bytes()[1 : bf.Len()-2], nil
}
func mergeParams(params ...interface{}) ([]byte, error) {
count := len(params) // 引數數量
bf := bytes.Buffer{}
// 引數數量
{
bf.WriteString("*")
bf.WriteString(strconv.Itoa(count))
bf.Write([]byte{'\r', '\n'})
}
for _, p := range params {
bf.Write([]byte{'$'})
switch p.(type) {
case string:
str := p.(string)
bf.WriteString(strconv.Itoa(len(str)))
bf.Write([]byte{'\r', '\n'})
bf.WriteString(str)
break
case int:
str := strconv.Itoa(p.(int))
bf.WriteString(strconv.Itoa(len(str)))
bf.Write([]byte{'\r', '\n'})
bf.WriteString(str)
break
case nil:
bf.WriteString("-1")
break
default:
// 不支援的引數型別
return nil, errors.New("引數只能是String或Int")
}
bf.Write([]byte{'\r', '\n'})
}
return bf.Bytes(), nil
}
實現幾個常用資料型別的解析
package redis4go
import (
"errors"
"strconv"
)
func (resp *RedisResp) ParseError() error {
if resp.rType != '-' {
return nil
}
return errors.New(string(resp.rData[0]))
}
func (resp *RedisResp) ParseInt() (int, error) {
switch resp.rType {
case '-':
return 0, resp.ParseError()
case '$':
fallthrough
case ':':
str, err := resp.ParseString()
if err != nil {
return 0, err
}
return strconv.Atoi(str)
default:
return 0, errors.New("錯誤的回覆型別")
}
}
func (resp *RedisResp) ParseString() (string, error) {
switch resp.rType {
case '-':
return "", resp.ParseError()
case '+':
fallthrough
case ':':
fallthrough
case '$':
return string(resp.rData[0]), nil
default:
return "", errors.New("錯誤的回覆型別")
}
}
func (resp *RedisResp) ParseList() ([]string, error) {
switch resp.rType {
case '-':
return nil, resp.ParseError()
case '*':
list := make([]string, 0, len(resp.rData))
for _, data := range resp.rData {
list = append(list, string(data))
}
return list, nil
default:
return nil, errors.New("錯誤的回覆型別")
}
}
func (resp *RedisResp) ParseMap() (map[string]string, error) {
switch resp.rType {
case '-':
return nil, resp.ParseError()
case '*':
mp := make(map[string]string)
for i := 0; i < len(resp.rData); i += 2 {
mp[string(resp.rData[i])] = string(resp.rData[i+1])
}
return mp, nil
default:
return nil, errors.New("錯誤的回覆型別")
}
}
在開發的過程中,隨手編寫了幾個零零散散的測試檔案,經測試,一些簡單的Redis命令以及能跑通了。
package redis4go
import (
"testing"
)
func getConn() (*RedisConn, error) {
pool, err := CreatePool(PoolConfig{
RedisConfig: RedisConfig{
Host: "127.0.0.1",
Port: 6379,
},
PoolSize: 10,
})
if err != nil {
return nil, err
}
conn := pool.getConn()
return conn, nil
}
func TestRedisResp_ParseString(t *testing.T) {
demoStr := string([]byte{'A', '\n', '\r', '\n', 'b', '1'})
conn, _ := getConn()
_, _ = conn.Call("del", "name")
_, _ = conn.Call("set", "name", demoStr)
resp, err := conn.Call("get", "name")
if err != nil {
t.Fatal("Call Error:", err.Error())
}
str, err := resp.ParseString()
if err != nil {
t.Fatal("Parse Error:", err.Error())
}
if str != demoStr {
t.Fatal("結果錯誤")
}
}
func TestRedisResp_ParseList(t *testing.T) {
conn, _ := getConn()
_, _ = conn.Call("del", "testList")
_, _ = conn.Call("lpush", "testList", 1, 2, 3, 4, 5)
res, err := conn.Call("lrange", "testList", 0, -1)
if err != nil {
t.Fatal("Call Error:", err.Error())
}
ls, err := res.ParseList()
if err != nil {
t.Fatal("Parse Error:", err.Error())
}
if len(ls) != 5 {
t.Fatal("結果錯誤")
}
}
func TestRedisResp_ParseMap(t *testing.T) {
conn, _ := getConn()
_, _ = conn.Call("del", "testMap")
_, err := conn.Call("hmset", "testMap", 1, 2, 3, 4, 5, 6)
if err != nil {
t.Fatal("設定Value失敗")
}
res, err := conn.Call("hgetall", "testMap")
if err != nil {
t.Fatal("Call Error:", err.Error())
}
ls, err := res.ParseMap()
if err != nil {
t.Fatal("Parse Error:", err.Error())
}
if len(ls) != 3 || ls["1"] != "2" {
t.Fatal("結果錯誤")
}
}
至此,已經算是達到了學習Go語言和學習Redis Protocol的目的,不過程式碼中也有很多地方需要優化和完善,效能方面考慮的也並不周全。輪子就不重複造了,畢竟有很多功能完善的庫,從頭造一個輪子需要消耗的精力太多啦並且沒必要~
下一次我將會學習官方推薦的gomodule/redigo
原始碼,並分享我的心得。
--The End--