Frontend Labs
⋯

Day 53 — Binary Protocol Design

Learning Objectives

  • Design a length-prefixed binary protocol for internal services
  • Use encoding/binary for 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.


Generated from week8.md · 8 Oct 2026 16:36