Day 53 — Binary Protocol Design
Learning Objectives
- Design a length-prefixed binary protocol for internal services
- Use
encoding/binaryfor byte-order-safe encoding - Benchmark binary vs JSON for internal communication
Key Concepts
Why binary protocols?
JSON: human-readable, verbose, allocates strings
Binary: compact, fast, no text parsing overhead
gRPC (protobuf) is 5-10x more efficient than JSON for same data
Length-prefix framing:
[4-byte length][payload bytes]
Receiver: read 4 bytes → parse length → read exactly that many bytes
Alternative: delimiter-based (e.g., newlines) — fragile with binary data
Protocol versioning:
Always include a version field — allows backward compatibility
[1-byte version][1-byte opcode][4-byte length][payload]
Exercise: Binary KV Protocol
package main
import (
"encoding/binary"
"fmt"
"io"
"net"
)
// Protocol: [1B version][1B opcode][4B key-len][key][4B val-len][val]
const protocolVersion = 1
type Opcode byte
const (
OpSet Opcode = 0x01
OpGet Opcode = 0x02
OpDelete Opcode = 0x03
OpOK Opcode = 0x10
OpNotFound Opcode = 0x11
OpError Opcode = 0xFF
)
type Message struct {
Version byte
Opcode Opcode
Key []byte
Value []byte
}
func WriteMessage(w io.Writer, msg Message) error {
// Header: version + opcode
if _, err := w.Write([]byte{msg.Version, byte(msg.Opcode)}); err != nil {
return err
}
// Key: 4-byte length + data
klen := make([]byte, 4)
binary.BigEndian.PutUint32(klen, uint32(len(msg.Key)))
if _, err := w.Write(klen); err != nil { return err }
if _, err := w.Write(msg.Key); err != nil { return err }
// Value: 4-byte length + data
vlen := make([]byte, 4)
binary.BigEndian.PutUint32(vlen, uint32(len(msg.Value)))
if _, err := w.Write(vlen); err != nil { return err }
if len(msg.Value) > 0 {
if _, err := w.Write(msg.Value); err != nil { return err }
}
return nil
}
func ReadMessage(r io.Reader) (Message, error) {
header := make([]byte, 2)
if _, err := io.ReadFull(r, header); err != nil {
return Message{}, fmt.Errorf("read header: %w", err)
}
msg := Message{Version: header[0], Opcode: Opcode(header[1])}
if msg.Version != protocolVersion {
return Message{}, fmt.Errorf("unsupported version %d", msg.Version)
}
readField := func() ([]byte, error) {
lenBuf := make([]byte, 4)
if _, err := io.ReadFull(r, lenBuf); err != nil {
return nil, err
}
length := binary.BigEndian.Uint32(lenBuf)
if length == 0 { return nil, nil }
if length > 10*1024*1024 { // 10MB max field size
return nil, fmt.Errorf("field too large: %d bytes", length)
}
data := make([]byte, length)
_, err := io.ReadFull(r, data)
return data, err
}
var err error
msg.Key, err = readField()
if err != nil { return Message{}, fmt.Errorf("read key: %w", err) }
msg.Value, err = readField()
if err != nil { return Message{}, fmt.Errorf("read value: %w", err) }
return msg, nil
}
func main() {
server, client := net.Pipe()
// Server: in-memory KV store
go func() {
defer server.Close()
store := make(map[string][]byte)
for {
msg, err := ReadMessage(server)
if err != nil { return }
var resp Message
resp.Version = protocolVersion
switch msg.Opcode {
case OpSet:
store[string(msg.Key)] = msg.Value
resp.Opcode = OpOK
case OpGet:
val, ok := store[string(msg.Key)]
if ok {
resp.Opcode = OpOK
resp.Value = val
} else {
resp.Opcode = OpNotFound
}
}
WriteMessage(server, resp)
}
}()
// Client: SET then GET
WriteMessage(client, Message{Version: protocolVersion, Opcode: OpSet, Key: []byte("user:1"), Value: []byte(`{"name":"Alice","role":"admin"}`)})
resp, _ := ReadMessage(client)
fmt.Printf("SET response: opcode=0x%02X\n", resp.Opcode)
WriteMessage(client, Message{Version: protocolVersion, Opcode: OpGet, Key: []byte("user:1")})
resp2, _ := ReadMessage(client)
fmt.Printf("GET response: value=%s\n", resp2.Value)
client.Close()
}
Real-World Context
Redis, Kafka, and MySQL all use binary protocols. Understanding framing prevents bugs like "reading a partial message" or "confusing the length with the data". The io.ReadFull call is critical — a plain Read might return less than requested on a busy network.