Chapter 1: RESP Protocol
Every Redis command travels over the network as RESP (Redis Serialization Protocol). In this chapter, we’ll build a complete RESP parser and serializer - the foundation for our Redis clone.Prerequisites: Go basics, understanding of TCP
Further Reading: Networking Fundamentals
Time: 2-3 hours
Outcome: Working RESP parser and writer
Further Reading: Networking Fundamentals
Time: 2-3 hours
Outcome: Working RESP parser and writer
What is RESP?
RESP is a simple, efficient text-based protocol. Every piece of data starts with a type indicator:Copy
┌─────────────────────────────────────────────────────────────────────────────┐
│ RESP DATA TYPES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ TYPE PREFIX EXAMPLE MEANING │
│ ──── ────── ─────── ─────── │
│ │
│ Simple String + +OK\r\n "OK" │
│ Error - -ERR unknown\r\n Error message │
│ Integer : :1000\r\n 1000 │
│ Bulk String $ $5\r\nhello\r\n "hello" (length prefix) │
│ Array * *2\r\n$3\r\nfoo\r\n ["foo", "bar"] │
│ $3\r\nbar\r\n │
│ Null $ $-1\r\n nil (null bulk string) │
│ │
│ ALL LINES END WITH \r\n (CRLF) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Why RESP?
- Human-readable for debugging
- Simple to implement
- Efficient to parse (O(n))
- Supports binary-safe strings
Real Examples
Client Request (SET command)
Copy
*3\r\n ← Array of 3 elements
$3\r\n ← Bulk string, 3 bytes
SET\r\n ← "SET"
$4\r\n ← Bulk string, 4 bytes
name\r\n ← "name"
$5\r\n ← Bulk string, 5 bytes
Alice\r\n ← "Alice"
Server Response
Copy
+OK\r\n ← Simple string "OK"
GET Response with Value
Copy
$5\r\n ← Bulk string, 5 bytes
Alice\r\n ← "Alice"
GET Response for Missing Key
Copy
$-1\r\n ← Null bulk string
Project Setup
Copy
mkdir myredis
cd myredis
go mod init myredis
Project Structure
Copy
myredis/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ └── protocol/
│ ├── types.go
│ ├── parser.go
│ └── writer.go
├── go.mod
└── README.md
Implementation
Step 1: Define RESP Types
internal/protocol/types.go
Copy
package protocol
import (
"errors"
"strconv"
)
// RESP type prefixes
const (
SimpleString = '+'
Error = '-'
Integer = ':'
BulkString = '$'
Array = '*'
)
// Common errors
var (
ErrInvalidSyntax = errors.New("invalid RESP syntax")
ErrUnexpectedEOF = errors.New("unexpected end of input")
ErrInvalidInteger = errors.New("invalid integer")
ErrInvalidBulkLen = errors.New("invalid bulk string length")
ErrInvalidArrayLen = errors.New("invalid array length")
)
// Value represents any RESP value
type Value struct {
Type byte // +, -, :, $, *
Str string // For SimpleString and Error
Num int64 // For Integer
Bulk []byte // For BulkString
Array []Value // For Array
Null bool // True if null bulk string or null array
}
// Common responses as package-level variables for reuse
var (
OK = Value{Type: SimpleString, Str: "OK"}
PONG = Value{Type: SimpleString, Str: "PONG"}
NullBulk = Value{Type: BulkString, Null: true}
NullArray = Value{Type: Array, Null: true}
Zero = Value{Type: Integer, Num: 0}
One = Value{Type: Integer, Num: 1}
)
// NewError creates an error response
func NewError(msg string) Value {
return Value{Type: Error, Str: msg}
}
// NewErrorf creates a formatted error response
func NewErrorf(format string, args ...interface{}) Value {
return Value{Type: Error, Str: fmt.Sprintf(format, args...)}
}
// NewSimpleString creates a simple string response
func NewSimpleString(s string) Value {
return Value{Type: SimpleString, Str: s}
}
// NewInteger creates an integer response
func NewInteger(n int64) Value {
return Value{Type: Integer, Num: n}
}
// NewBulkString creates a bulk string response
func NewBulkString(s string) Value {
return Value{Type: BulkString, Bulk: []byte(s)}
}
// NewBulkBytes creates a bulk string from bytes
func NewBulkBytes(b []byte) Value {
return Value{Type: BulkString, Bulk: b}
}
// NewArray creates an array response
func NewArray(vals []Value) Value {
return Value{Type: Array, Array: vals}
}
// IsNull returns true if this is a null value
func (v Value) IsNull() bool {
return v.Null
}
// AsString returns the string representation of the value
func (v Value) AsString() string {
switch v.Type {
case SimpleString, Error:
return v.Str
case BulkString:
return string(v.Bulk)
case Integer:
return strconv.FormatInt(v.Num, 10)
default:
return ""
}
}
// AsBytes returns the value as bytes
func (v Value) AsBytes() []byte {
switch v.Type {
case SimpleString, Error:
return []byte(v.Str)
case BulkString:
return v.Bulk
default:
return nil
}
}
// AsInt returns the value as int64
func (v Value) AsInt() (int64, error) {
switch v.Type {
case Integer:
return v.Num, nil
case BulkString:
return strconv.ParseInt(string(v.Bulk), 10, 64)
case SimpleString:
return strconv.ParseInt(v.Str, 10, 64)
default:
return 0, errors.New("cannot convert to integer")
}
}
Step 2: Build the Parser
internal/protocol/parser.go
Copy
package protocol
import (
"bufio"
"fmt"
"io"
"strconv"
)
// Parser reads RESP values from a buffered reader
type Parser struct {
reader *bufio.Reader
}
// NewParser creates a new RESP parser
func NewParser(r io.Reader) *Parser {
return &Parser{
reader: bufio.NewReaderSize(r, 64*1024), // 64KB buffer
}
}
// Parse reads and returns the next RESP value
func (p *Parser) Parse() (Value, error) {
// Read the type indicator
typeByte, err := p.reader.ReadByte()
if err != nil {
return Value{}, err
}
switch typeByte {
case SimpleString:
return p.parseSimpleString()
case Error:
return p.parseError()
case Integer:
return p.parseInteger()
case BulkString:
return p.parseBulkString()
case Array:
return p.parseArray()
default:
// Handle inline commands (commands without RESP framing)
p.reader.UnreadByte()
return p.parseInline()
}
}
// parseSimpleString reads: +OK\r\n
func (p *Parser) parseSimpleString() (Value, error) {
line, err := p.readLine()
if err != nil {
return Value{}, err
}
return Value{Type: SimpleString, Str: line}, nil
}
// parseError reads: -ERR message\r\n
func (p *Parser) parseError() (Value, error) {
line, err := p.readLine()
if err != nil {
return Value{}, err
}
return Value{Type: Error, Str: line}, nil
}
// parseInteger reads: :1000\r\n
func (p *Parser) parseInteger() (Value, error) {
line, err := p.readLine()
if err != nil {
return Value{}, err
}
num, err := strconv.ParseInt(line, 10, 64)
if err != nil {
return Value{}, ErrInvalidInteger
}
return Value{Type: Integer, Num: num}, nil
}
// parseBulkString reads: $5\r\nhello\r\n or $-1\r\n (null)
func (p *Parser) parseBulkString() (Value, error) {
// Read length line
line, err := p.readLine()
if err != nil {
return Value{}, err
}
length, err := strconv.Atoi(line)
if err != nil {
return Value{}, ErrInvalidBulkLen
}
// Handle null bulk string
if length < 0 {
return Value{Type: BulkString, Null: true}, nil
}
// Read exactly 'length' bytes + \r\n
buf := make([]byte, length+2)
_, err = io.ReadFull(p.reader, buf)
if err != nil {
return Value{}, err
}
// Verify CRLF terminator
if buf[length] != '\r' || buf[length+1] != '\n' {
return Value{}, ErrInvalidSyntax
}
return Value{Type: BulkString, Bulk: buf[:length]}, nil
}
// parseArray reads: *3\r\n followed by 3 elements
func (p *Parser) parseArray() (Value, error) {
// Read count line
line, err := p.readLine()
if err != nil {
return Value{}, err
}
count, err := strconv.Atoi(line)
if err != nil {
return Value{}, ErrInvalidArrayLen
}
// Handle null array
if count < 0 {
return Value{Type: Array, Null: true}, nil
}
// Parse each element
array := make([]Value, count)
for i := 0; i < count; i++ {
val, err := p.Parse()
if err != nil {
return Value{}, err
}
array[i] = val
}
return Value{Type: Array, Array: array}, nil
}
// parseInline handles commands sent without RESP framing
// e.g., "PING\r\n" or "SET foo bar\r\n"
func (p *Parser) parseInline() (Value, error) {
line, err := p.readLine()
if err != nil {
return Value{}, err
}
// Split by spaces
parts := splitInline(line)
if len(parts) == 0 {
return Value{}, ErrInvalidSyntax
}
// Convert to array of bulk strings
array := make([]Value, len(parts))
for i, part := range parts {
array[i] = Value{Type: BulkString, Bulk: []byte(part)}
}
return Value{Type: Array, Array: array}, nil
}
// readLine reads until \r\n and returns the line without CRLF
func (p *Parser) readLine() (string, error) {
line, err := p.reader.ReadString('\n')
if err != nil {
return "", err
}
// Remove \r\n
if len(line) < 2 || line[len(line)-2] != '\r' {
return "", ErrInvalidSyntax
}
return line[:len(line)-2], nil
}
// splitInline splits an inline command respecting quotes
func splitInline(line string) []string {
var parts []string
var current []byte
inQuote := false
quoteChar := byte(0)
for i := 0; i < len(line); i++ {
c := line[i]
if !inQuote && (c == '"' || c == '\'') {
inQuote = true
quoteChar = c
} else if inQuote && c == quoteChar {
inQuote = false
} else if !inQuote && c == ' ' {
if len(current) > 0 {
parts = append(parts, string(current))
current = nil
}
} else {
current = append(current, c)
}
}
if len(current) > 0 {
parts = append(parts, string(current))
}
return parts
}
Step 3: Build the Writer
internal/protocol/writer.go
Copy
package protocol
import (
"bufio"
"io"
"strconv"
)
// Writer serializes RESP values to a buffered writer
type Writer struct {
writer *bufio.Writer
}
// NewWriter creates a new RESP writer
func NewWriter(w io.Writer) *Writer {
return &Writer{
writer: bufio.NewWriter(w),
}
}
// Write serializes a Value and writes it to the underlying writer
func (w *Writer) Write(v Value) error {
if err := w.writeValue(v); err != nil {
return err
}
return w.writer.Flush()
}
// WriteValues writes multiple values
func (w *Writer) WriteValues(vals ...Value) error {
for _, v := range vals {
if err := w.writeValue(v); err != nil {
return err
}
}
return w.writer.Flush()
}
func (w *Writer) writeValue(v Value) error {
switch v.Type {
case SimpleString:
return w.writeSimpleString(v.Str)
case Error:
return w.writeError(v.Str)
case Integer:
return w.writeInteger(v.Num)
case BulkString:
return w.writeBulkString(v)
case Array:
return w.writeArray(v)
default:
return ErrInvalidSyntax
}
}
func (w *Writer) writeSimpleString(s string) error {
w.writer.WriteByte(SimpleString)
w.writer.WriteString(s)
w.writer.WriteString("\r\n")
return nil
}
func (w *Writer) writeError(s string) error {
w.writer.WriteByte(Error)
w.writer.WriteString(s)
w.writer.WriteString("\r\n")
return nil
}
func (w *Writer) writeInteger(n int64) error {
w.writer.WriteByte(Integer)
w.writer.WriteString(strconv.FormatInt(n, 10))
w.writer.WriteString("\r\n")
return nil
}
func (w *Writer) writeBulkString(v Value) error {
w.writer.WriteByte(BulkString)
if v.Null {
w.writer.WriteString("-1\r\n")
return nil
}
w.writer.WriteString(strconv.Itoa(len(v.Bulk)))
w.writer.WriteString("\r\n")
w.writer.Write(v.Bulk)
w.writer.WriteString("\r\n")
return nil
}
func (w *Writer) writeArray(v Value) error {
w.writer.WriteByte(Array)
if v.Null {
w.writer.WriteString("-1\r\n")
return nil
}
w.writer.WriteString(strconv.Itoa(len(v.Array)))
w.writer.WriteString("\r\n")
for _, elem := range v.Array {
if err := w.writeValue(elem); err != nil {
return err
}
}
return nil
}
// Flush ensures all buffered data is written
func (w *Writer) Flush() error {
return w.writer.Flush()
}
Testing the Parser
internal/protocol/parser_test.go
Copy
package protocol
import (
"strings"
"testing"
)
func TestParseSimpleString(t *testing.T) {
input := "+OK\r\n"
parser := NewParser(strings.NewReader(input))
val, err := parser.Parse()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val.Type != SimpleString || val.Str != "OK" {
t.Errorf("expected SimpleString 'OK', got %v", val)
}
}
func TestParseBulkString(t *testing.T) {
input := "$5\r\nhello\r\n"
parser := NewParser(strings.NewReader(input))
val, err := parser.Parse()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val.Type != BulkString || string(val.Bulk) != "hello" {
t.Errorf("expected BulkString 'hello', got %v", val)
}
}
func TestParseArray(t *testing.T) {
// *2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n
input := "*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n"
parser := NewParser(strings.NewReader(input))
val, err := parser.Parse()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val.Type != Array || len(val.Array) != 2 {
t.Errorf("expected Array of 2 elements, got %v", val)
}
if string(val.Array[0].Bulk) != "foo" || string(val.Array[1].Bulk) != "bar" {
t.Errorf("expected ['foo', 'bar'], got %v", val)
}
}
func TestParseNullBulkString(t *testing.T) {
input := "$-1\r\n"
parser := NewParser(strings.NewReader(input))
val, err := parser.Parse()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val.Type != BulkString || !val.Null {
t.Errorf("expected null BulkString, got %v", val)
}
}
Copy
go test ./internal/protocol/...
Exercises
Exercise 1: Add RESP3 support
Exercise 1: Add RESP3 support
RESP3 (Redis 6+) adds more types:Extend your parser to handle these types.
Copy
,3.14\r\n # Double
#t\r\n # Boolean true
#f\r\n # Boolean false
!21\r\n # Blob error
SYNTAX error here\r\n
Exercise 2: Benchmark the parser
Exercise 2: Benchmark the parser
Create benchmarks for your parser:Compare with different buffer sizes.
Copy
func BenchmarkParseBulkString(b *testing.B) {
input := "$1000\r\n" + strings.Repeat("x", 1000) + "\r\n"
for i := 0; i < b.N; i++ {
parser := NewParser(strings.NewReader(input))
parser.Parse()
}
}
Exercise 3: Stream parsing
Exercise 3: Stream parsing
Implement a channel-based parser that streams values:
Copy
func (p *Parser) ParseStream(ctx context.Context) <-chan Value {
ch := make(chan Value)
go func() {
defer close(ch)
for {
val, err := p.Parse()
if err != nil {
return
}
select {
case ch <- val:
case <-ctx.Done():
return
}
}
}()
return ch
}
Key Takeaways
Type Prefixes
Every RESP value starts with a type indicator: +, -, :, $, or *
Length-Prefixed
Bulk strings and arrays include their length for efficient parsing
Binary Safe
Bulk strings can contain any bytes, including nulls
CRLF Terminated
All lines end with \r\n for cross-platform compatibility
What’s Next?
In Chapter 2: TCP Server, we’ll:- Build a TCP server that accepts connections
- Handle multiple clients concurrently with goroutines
- Integrate our RESP parser
Next: TCP Server
Build the network layer for your Redis clone