Computer >> Hướng Dẫn Máy Tính >  >> Lập Trình >> Redis

Thành Thạo Redis Protocol Trong Go: Triển Khai Đọc & Ghi

Trong bài viết này, tôi phác thảo một triển khai đơn giản, dễ hiểu cho hai thành phần của một Redis client trong Go như một cách để hiểu cách Redis protocol hoạt động và điều gì làm nó tuyệt vời.

Nếu bạn đang tìm một Redis client đầy đủ tính năng, sẵn sàng production trong Go, chúng tôi khuyên bạn nên xem thư viện redigo của Gary Burd.

Trước khi bắt đầu, hãy chắc chắn bạn đã đọc giới thiệu nhẹ nhàng của chúng tôi về Redis protocol – nó bao gồm các cơ bản của protocol mà bạn sẽ cần cho hướng dẫn này.

Một RESP Command Writer Trong Go

Đối với Redis client giả định của chúng ta, chỉ có một loại đối tượng mà chúng ta sẽ cần ghi: một array của bulk strings để gửi lệnh đến Redis. Đây là một triển khai đơn giản của command-to-RESP writer:

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) {
  // Ghi array prefix và số lượng argument trong array.
  w.Write(arrayPrefixSlice)
  w.WriteString(strconv.Itoa(len(args)))
  w.Write(lineEndingSlice)
  // Ghi một bulk string cho mỗi argument.
  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()
}

Thay vì ghi vào đối tượng net.Conn, RESPWriter ghi vào đối tượng io.Writer. Điều này cho phép chúng ta test parser của mình mà không bị ràng buộc chặt chẽ vào net stack. Chúng ta đơn giản test giao thức mạng như chúng ta test bất kỳ io nào khác.

Ví dụ, chúng ta có thể truyền cho nó một bytes.Buffer để kiểm tra RESP cuối cùng:

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

Một Simple RESP Reader Trong Go

Sau khi gửi lệnh đến Redis với RESPWriter, client của chúng ta sẽ dùng RESPReader để đọc từ kết nối TCP cho đến khi nó nhận được một full RESP reply. Để bắt đầu, chúng ta sẽ cần một vài package để xử lý buffering và parsing dữ liệu đến:

package redis
import (
  "bufio"
  "bytes"
  "errors"
  "io"
  "strconv"
)

Và chúng ta sẽ dùng một vài biến và hằng số để làm cho code của chúng ta dễ đọc hơn:

const (
  SIMPLE_STRING = '+'
  BULK_STRING = '$'
  INTEGER = ':'
  ARRAY = '*'
  ERROR = '-'
)
var (
  ErrInvalidSyntax = errors.New("resp: invalid syntax")
)

Giống như RESPWriter, RESPReader không quan tâm đến chi tiết triển khai của đối tượng mà nó đọc RESP từ. Nó chỉ cần khả năng đọc byte cho đến khi nó đã đọc một full RESP object. Trong trường hợp này, nó cần một io.Reader, mà nó wrap với một bufio.Reader để xử lý buffering dữ liệu đến.

Object và initializer của chúng ta đơn giản:

type RESPReader struct {
  *bufio.Reader
}
func NewReader(reader io.Reader) *RESPReader {
  return &RESPReader{
    Reader: bufio.NewReaderSize(reader, 32*1024),
  }
}

Kích thước buffer cho bufio.Reader chỉ là một ước lượng trong lúc phát triển. Trong một client thực tế, bạn sẽ muốn làm cho kích thước của nó có thể cấu hình và có thể test để tìm kích thước tối ưu. 32KB sẽ hoạt động tốt cho phát triển.

RESPReader chỉ có một phương thức: ReadObject(), phương thức này trả về một byte slice chứa một full RESP object trên mỗi lần gọi. Nó sẽ truyền lại bất kỳ lỗi nào gặp phải từ io.Reader, và cũng sẽ trả về lỗi khi nó gặp bất kỳ cú pháp RESP không hợp lệ nào.

Tính chất prefix của RESP có nghĩa là chúng ta chỉ cần đọc byte đầu tiên để quyết định cách xử lý các byte tiếp theo. Tuy nhiên, vì chúng ta sẽ luôn cần đọc ít nhất dòng đầu tiên đầy đủ (tức là đến \r\n đầu tiên), chúng ta có thể bắt đầu bằng cách đọc toàn bộ dòng đầu tiên:

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
  }
}

Khi dòng chúng ta đọc có tiền tố simple string, integer, hoặc error, chúng ta trả về toàn bộ dòng làm RESP object nhận được vì các kiểu object đó được chứa hoàn toàn trong một dòng.

Trong readLine(), chúng ta đọc đến lần xuất hiện đầu tiên của \n và sau đó kiểm tra để chắc chắn rằng nó được tiền tố bởi \r trước khi trả về dòng như một byte slice:

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 {
    // Dòng quá ngắn hoặc \n không được tiền tố bởi \r.
    return nil, ErrInvalidSyntax
  }
}

Trong readBulkString() chúng ta parse length specification cho bulk string để biết cần đọc bao nhiêu byte. Một khi đã biết, chúng ta đọc số byte đó và line terminator \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
}

Tôi đã tách getCount() ra thành một phương thức riêng vì length specification cũng được dùng cho arrays:

func (r *RESPReader) getCount(line []byte) (int, error) {
  end := bytes.IndexByte(line, '\r')
  return strconv.Atoi(string(line[1:end]))
}

Để xử lý arrays, chúng ta lấy số lượng phần tử array, và sau đó gọi ReadObject() đệ quy, thêm các object kết quả vào RESP buffer hiện tại của chúng ta:

func (r *RESPReader) readArray(line []byte) ([]byte, error) {
  // Lấy số lượng phần tử array.
  count, err := r.getCount(line)
  if err != nil {
    return nil, err
  }
  // Đọc `count` số lượng RESP object trong array.
  for i := 0; i < count; i++ {
    buf, err := r.ReadObject()
    if err != nil {
      return nil, err
    }
    line = append(line, buf...)
  }
  return line, nil
}

Tổng Kết

Trên trăm dòng code ở trên là tất cả những gì cần thiết để đọc bất kỳ RESP object nào từ Redis. Tuy nhiên, có một số phần còn thiếu chúng ta cần triển khai trước khi dùng thư viện này trong môi trường production:

  • Khả năng trích xuất giá trị thực tế từ RESP. RESPReader hiện chỉ trả về full RESP response, nó không, ví dụ, trả về một string từ bulk string response. Tuy nhiên, việc triển khai điều này sẽ dễ dàng.
  • RESPReader cần xử lý lỗi cú pháp tốt hơn.

Code này cũng hoàn toàn chưa được tối ưu và làm nhiều allocation và copy hơn cần thiết. Ví dụ, phương thức readArray(): cho mỗi object trong array, chúng ta đọc object và sau đó copy nó vào buffer cục bộ của chúng ta.

Nếu bạn quan tâm đến việc học cách triển khai các phần này, tôi khuyên bạn nên xem cách các thư viện phổ biến như hiredis hoặc redigo triển khai chúng.

Cảm ơn đặc biệt Niel Smith đã giúp chúng tôi bắt một số bug trong code chứa trong bài viết này.