Computer >> 컴퓨터 >  >> 프로그래밍 >> Redis

Go 언어로 배우는 Redis 프로토콜(RESP) 읽기와 쓰기

이 글에서는 Redis 프로토콜이 어떻게 동작하는지, 그리고 무엇이 이 프로토콜을 훌륭하게 만드는지 이해하기 위해 Go 언어로 작성된 Redis 클라이언트의 두 가지 핵심 컴포넌트를 간단하고 이해하기 쉬운 방식으로 구현해 보겠습니다.

만약 기능이 완전하고 프로덕션 환경에서 바로 사용할 수 있는 Go용 Redis 클라이언트를 찾고 계신다면, Gary Burd의 redigo 라이브러리를 살펴보시길 권합니다.

시작하기 전에, Redis 프로토콜에 대한 입문 가이드를 먼저 읽어보시기 바랍니다. 이 글을 이해하는 데 필요한 프로토콜의 기본 개념들을 모두 다루고 있습니다.

Go로 구현하는 RESP 커맨드 라이터(Command Writer)

가상의 Redis 클라이언트를 만든다고 가정하면, 우리가 작성해야 할 객체는 사실상 한 가지뿐입니다. 바로 Redis에 명령어를 전송하기 위한 벌크 스트링(bulk string) 배열입니다. 다음은 명령어를 RESP 형식으로 변환해 출력하는 라이터의 간단한 구현 예제입니다:

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) {
  // Write the array prefix and the number of arguments in the array.
  w.Write(arrayPrefixSlice)
  w.WriteString(strconv.Itoa(len(args)))
  w.Write(lineEndingSlice)

  // Write a bulk string for each 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()
}

RESPWriternet.Conn 객체 대신 io.Writer 객체에 데이터를 기록합니다. 덕분에 net 패키지에 강하게 결합되지 않고도 파서를 테스트할 수 있습니다. 즉, 다른 io 객체를 테스트하듯 네트워크 프로토콜도 손쉽게 검증할 수 있습니다.

예를 들어 bytes.Buffer를 전달하면 최종적으로 생성된 RESP를 확인할 수 있습니다:

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

Go로 구현하는 간단한 RESP 리더(Reader)

RESPWriter로 Redis에 명령어를 전송한 후, 클라이언트는 RESPReader를 사용해 TCP 연결에서 완전한 RESP 응답을 받을 때까지 데이터를 읽습니다. 먼저 수신 데이터의 버퍼링과 파싱을 처리하기 위해 몇 가지 패키지를 임포트해야 합니다:

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를 읽어오는 대상 객체의 내부 구현 세부사항에는 관심이 없습니다. 완전한 RESP 객체를 읽을 때까지 바이트를 읽을 수 있는 능력만 있으면 됩니다. 따라서 io.Reader를 받아 bufio.Reader로 감싸 수신 데이터의 버퍼링을 처리합니다.

객체와 초기화 함수는 매우 간단합니다:

type RESPReader struct {
  *bufio.Reader
}

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

bufio.Reader의 버퍼 크기는 개발 단계에서 어림잡아 정한 값입니다. 실제 클라이언트에서는 크기를 설정 가능하게 만들고, 최적의 크기를 찾기 위해 테스트해 보는 것이 좋습니다. 개발 용도로는 32KB면 충분합니다.

RESPReader에는 ReadObject()라는 메서드 하나만 있습니다. 이 메서드는 호출될 때마다 완전한 RESP 객체를 담은 바이트 슬라이스를 반환합니다. io.Reader에서 발생한 에러는 그대로 전달하며, 잘못된 RESP 문법을 만나면 역시 에러를 반환합니다.

RESP는 접두사(prefix) 기반 프로토콜이므로, 첫 번째 바이트만 읽으면 이후 바이트들을 어떻게 처리할지 결정할 수 있습니다. 하지만 적어도 첫 줄 전체(즉, 첫 번째 \r\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
  }
}

읽어들인 줄이 단순 문자열(simple string), 정수(integer), 또는 에러(error) 접두사로 시작한다면, 해당 타입들은 한 줄 안에 온전히 담겨 있으므로 그 줄 전체를 RESP 객체로 반환합니다.

readLine()에서는 \n이 나타날 때까지 읽은 후, 그 앞에 \r이 있는지 확인한 뒤 해당 줄을 바이트 슬라이스로 반환합니다:

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()에서는 벌크 스트링의 길이 정보를 파싱해 몇 바이트를 더 읽어야 하는지 알아냅니다. 그런 다음 해당 길이만큼의 바이트와 \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()를 재귀적으로 호출하여, 결과로 나온 객체들을 현재 RESP 버퍼에 차례대로 추가합니다:

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 응답 전체만 반환할 뿐, 예를 들어 벌크 스트링 응답에서 문자열 자체를 추출하지는 않습니다. 다만 이를 구현하는 것은 어렵지 않습니다.
  • RESPReader에는 더 나은 문법 오류 처리가 필요합니다.

또한 이 코드는 최적화가 전혀 되어 있지 않아 불필요한 메모리 할당과 복사가 많이 일어납니다. 예를 들어 readArray() 메서드는 배열의 각 객체를 읽은 후 로컬 버퍼로 복사합니다.

이러한 기능들을 구현하는 방법에 관심이 있다면, hiredis나 redigo 같은 인기 라이브러리들이 어떻게 구현했는지 살펴보시길 권합니다.

마지막으로, 이 글의 코드에서 몇 가지 버그를 찾는 데 도움을 주신 Niel Smith님께 감사드립니다.