【轉】使用 Go 語言讀寫 Redis 協議

JaguarJack發表於2019-08-16

原文: Reading and Writing Redis Protocol in Go\
翻譯整理: smallnest, 譯文連線: 使用 Go 語言讀寫Redis協議。 轉載請保留原文出處和譯文譯者和出處。

這篇文章使用兩個簡單的ReaderWriter實現了 Redis 客戶端的讀寫協議,通過這兩個實現可以容易地學習 Redis 協議是如何工作的。

如果你想尋找一個全功能的、產品級的Redis client, 推薦你看看 Gary Burd的 redigo

開始之前,建議你先閱讀一下 Redis 協議的介紹。

官方的協議可以在其網站上找到: protocolRedis 的協議叫做 RESP (REdis Serialization Protocol),客戶端和伺服器端通過基於文字的協議進行通訊。

所有的伺服器和客戶端之間的通訊都使用以下 5 中基本型別:

  • 簡單字串: 伺服器用來返回簡單的結果,比如"OK"或者"PONG"
  • bulk string: 大部分單值命令的返回結果,比如 GET, LPOP, and HGET
  • 整數: 查詢長度的命令的返回結果
  • 陣列: 可以包含其它RESP物件,設定陣列,用來傳送命令給伺服器,也用來返回多個值的命令
  • Error: 伺服器返回錯誤資訊

RESP的第一個位元組表示資料的型別:

  • 簡單字串: 第一個位元組是 "+", 比如 "+OK\r\n"
  • bulk string: 第一個位元組是 "$", 比如 "$6\r\nfoobar\r\n"
  • 整數: 第一個位元組是 ":", 比如 ":1000\r\n"
  • 陣列: 第一個位元組是 "", 比如 "2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n"
  • Error: 第一個位元組是 "-", 比如 "-Error message\r\n"

基本瞭解Redis的協議之後,我們就可以實現它的讀寫器了。

RESPWriter

假定我們要實現的client很簡單,只會傳送 bulk string給伺服器,下面的程式碼是它的實現:

package redis

import (

  "bufio"

  "io"

  "strconv"     // for converting integers to strings

)

var (

  arrayPrefixSlice      = []byte{'*'}

  bulkStringPrefixSlice = []byte{'$'}

  lineEndingSlice       = []byte{'\r', '\n'}

)

type RESPWriter struct {

  *bufio.Writer

}

func NewRESPWriter(writer io.Writer) *RESPWriter {

  return &RESPWriter{

    Writer: bufio.NewWriter(writer),

  }

}

func (w *RESPWriter) WriteCommand(args ...string) (err error) {

  // 首先寫入陣列的標誌和陣列的數量

  w.Write(arrayPrefixSlice)

  w.WriteString(strconv.Itoa(len(args)))

  w.Write(lineEndingSlice)

  // 寫入批量字串

  for _, arg := range args {

    w.Write(bulkStringPrefixSlice)

    w.WriteString(strconv.Itoa(len(arg)))

    w.Write(lineEndingSlice)

    w.WriteString(arg)

    w.Write(lineEndingSlice)

  }

  return w.Flush()

}

注意我們並沒有直接寫入net.Conn,而是寫入一個io.Writer物件中,這樣方便我們寫測試程式碼,測試程式碼中不必以來net包。

例如,我們可以使用bytes.Buffer來測試我們傳送的命令:


var buf bytes.Buffer

writer := NewRESPWriter(&buf)

writer.WriteCommand("GET", "foo")

buf.Bytes() // *2\r\n$3\r\nGET\r\n$3\r\nfoo\r\n

RESPReader

客戶端使用 RESPWriter 來寫命令,而RESPReader用來讀取返回結果, 它嘗試從net.Conn中一直讀取資料,直到讀取到一個完整的響應。

需要引入的包:


package redis

import (

  "bufio"

  "bytes"

  "errors"

  "io"

  "strconv"

)

定義常量和錯誤:

const (

  SIMPLE_STRING = '+'

  BULK_STRING   = '$'

  INTEGER       = ':'

  ARRAY         = '*'

  ERROR         = '-'

)

var (

  ErrInvalidSyntax = errors.New("resp: invalid syntax")

)

RESPWriter一樣, RESPReader並不關心它讀取到的物件的細節,它所有的工作就是從連線中讀取一個完整的RESP物件。 它需要傳入一個io.Reade用來讀取,並且在內部使用bufio.Reader包裝了一下。RESPReader的定義很簡單:

type RESPReader struct {

  *bufio.Reader

}

func NewReader(reader io.Reader) *RESPReader {

  return &RESPReader{

    Reader: bufio.NewReaderSize(reader, 32*1024),

  }

}

這個快取大小隻是在開發過程中拍腦袋定的,在實際使用中,你可以需要它是可配置的,並且在測試中進行調優。32KB在開發測試中應該沒有問題。

RESPReader只有一個暴露的方法:ReadObject(),它返回這個RESP Object的位元組slice。它會返回讀取中的錯誤,以及解析命令的時候的錯誤。

RESP的第一個位元組意味這我們只需讀取第一個位元組就可以直到如何處理接下來的資料,但是我們總是需要讀取一行資料,所以我們還是先讀取第一行再處理:


func (r *RESPReader) ReadObject() ([]byte, error) {

  line, err := r.readLine()

  if err != nil {

    return nil, err

  }

  switch line[0] {

  case SIMPLE_STRING, INTEGER, ERROR:

    return line, nil

  case BULK_STRING:

    return r.readBulkString(line)

  case ARRAY:

    return r.readArray(line) default:

    return nil, ErrInvalidSyntax

  }

}

如果我們讀取的這一行是簡單字串、整數或者是Error,我們只需返回這完整的一行就可以了,因為這一行包含了完整的RESP物件。\
readLine方法中,我們一直讀取直到讀取到\n,並且檢查它前一個字元是否是\r, 如果是返回這一行資料 (注意line結尾中包含\r\n):


func (r *RESPReader) readLine() (line []byte, err error) {

  line, err = r.ReadBytes('\n')

  if err != nil {

    return nil, err

  }

  if len(line) > 1 && line[len(line)-2] == '\r' {

    return line, nil

  } else {

    // Line was too short or \n wasn't preceded by \r.

    return nil, ErrInvalidSyntax

  }

}

接下來我們在看看readBulkString的實現。我們需要解析長度值,以便我們接下來決定要讀取多少位元組。這樣我們可以讀取count位元組的資料,並且再讀取\r\n:


func (r *RESPReader) readBulkString(line []byte) ([]byte, error) {

  count, err := r.getCount(line)

  if err != nil {

    return nil, err

  }

  if count == -1 {

    return line, nil

  }

  buf := make([]byte, len(line)+count+2)

  copy(buf, line)

  _, err = io.ReadFull(r, buf[len(line):])

  if err != nil {

    return nil, err

  }

  return buf, nil

}

其中getCount單獨抽取出來了,因為解析陣列的時候也需要它:


func (r *RESPReader) getCount(line []byte) (int, error) {

  end := bytes.IndexByte(line, '\r')

  return strconv.Atoi(string(line[1:end]))

}

為了處理陣列,我們首先需要解析陣列的數量,然後迴圈地呼叫ReadObject,將讀取到的位元組slice放入到結果buffer中:

func (r *RESPReader) readArray(line []byte) ([]byte, error) {

  // Get number of array elements.

  count, err := r.getCount(line)

  if err != nil {

    return nil, err

  }

  // Read `count` number of RESP objects in the array.

  for i := 0; i < count; i++ {

    buf, err := r.ReadObject()

    if err != nil {

      return nil, err

    }

    line = append(line, buf...)

  }

  return line, nil

}

最後總結

上面的百行程式碼就實現了完整的讀寫Redis RESP物件,但是在應用到產品環境之前,還有一些東西需要補上:

  • 需要從RESP物件中讀取實際的值。當前RESPReader只是返回整個的RESP位元組slice,它並沒有返回字串或者整數,當然實現起來也很容易
  • RESPReader需要更好的錯誤處理

程式碼也沒進行優化,對於記憶體分配和複製的優化也沒有做,你可以看看成熟的產品級的redis庫的實現。

如果想在伺服器端讀寫Redis命令,可以考慮tidwall/redcon庫。

相關文章