Crea il tuo Redis in Go
Redis è uno dei datastore in memoria più diffusi: viene usato come cache, come broker di messaggi e come database chiave-valore ad alte prestazioni. Dietro la sua apparente semplicità si nascondono però alcuni concetti molto istruttivi: un server TCP concorrente, un protocollo di comunicazione testuale e binario insieme, strutture dati condivise tra più client e politiche di scadenza delle chiavi. Reimplementarne un sottoinsieme è uno degli esercizi migliori per capire come funziona davvero un server di rete.
In questo articolo costruiremo da zero un server compatibile con Redis scritto in Go. L'obiettivo non è clonare l'intero prodotto, ma realizzare un server funzionante che parli lo stesso protocollo di Redis, così da poterlo interrogare direttamente con il client ufficiale redis-cli. Implementeremo i comandi più comuni su stringhe, hash e liste, la gestione della scadenza delle chiavi e una forma basilare di persistenza su disco.
Go è particolarmente adatto a questo scopo: la libreria standard offre tutto il necessario per la rete con il pacchetto net, le goroutine rendono naturale gestire più client contemporaneamente e il pacchetto sync mette a disposizione i mutex per proteggere lo stato condiviso. Non serve alcuna dipendenza esterna.
Il protocollo RESP
Ogni comunicazione tra un client Redis e il server avviene tramite RESP, acronimo di REdis Serialization Protocol. È un protocollo testuale, semplice da leggere e da implementare, in cui ogni messaggio è preceduto da un byte che ne indica il tipo e ogni riga termina con la sequenza \r\n (CRLF).
RESP prevede cinque tipi fondamentali, ciascuno riconoscibile dal suo primo byte:
+OK\r\n simple string
-ERR messaggio\r\n errore
:42\r\n intero
$5\r\nhello\r\n bulk string (5 byte)
$-1\r\n bulk string nulla (chiave assente)
*2\r\n... array di 2 elementi
La cosa importante da capire è che un client invia sempre i comandi come array di bulk string. Quando da redis-cli scriviamo GET nome, sul canale TCP viaggiano in realtà questi byte:
*2\r\n$3\r\nGET\r\n$4\r\nnome\r\n
Scomponendolo riga per riga risulta più chiaro:
*2\r\n -> array di 2 elementi
$3\r\n -> bulk string lunga 3 byte
GET\r\n -> contenuto della prima bulk string
$4\r\n -> bulk string lunga 4 byte
nome\r\n -> contenuto della seconda bulk string
Il nostro server dovrà quindi saper fare due cose: leggere un array di bulk string in arrivo dal client e scrivere una risposta nel formato corretto a seconda del tipo di dato restituito dal comando.
Struttura del progetto
Iniziamo creando il modulo e organizzando il codice in file con responsabilità ben distinte:
goredis/
go.mod
resp.go // lettura e scrittura del protocollo RESP
store.go // contenitore dati in memoria, thread-safe
handler.go // implementazione dei singoli comandi
aof.go // persistenza append-only
main.go // server TCP e ciclo delle connessioni
Il file go.mod è minimale:
module goredis
go 1.22
Leggere e scrivere il protocollo RESP
Il cuore del server è il pacchetto che traduce i byte grezzi in strutture Go e viceversa. Definiamo un tipo Value capace di rappresentare qualunque valore RESP, un Reader che decodifica i dati in arrivo e un Writer che serializza le risposte.
Il Reader legge un byte alla volta per riconoscere il tipo, poi delega a funzioni specializzate. Per gli array conosce in anticipo il numero di elementi, mentre per le bulk string conosce in anticipo la lunghezza in byte: questo permette di gestire correttamente anche valori che contengono spazi o ritorni a capo. Il Writer fa il percorso inverso, costruendo la rappresentazione binaria di ogni tipo.
package main
import (
"bufio"
"io"
"strconv"
)
// I cinque tipi di dato previsti dal protocollo RESP, identificati dal primo byte
const (
stringType = '+'
errorType = '-'
integerType = ':'
bulkType = '$'
arrayType = '*'
)
// Value rappresenta un qualsiasi valore RESP, sia in lettura che in scrittura
type Value struct {
typ byte // il tipo del valore
str string // usato per le simple string e i messaggi di errore
num int // usato per gli interi
bulk string // usato per le bulk string
array []Value // usato per gli array
isNull bool // segnala una bulk string nulla ($-1)
}
// Reader incapsula un bufio.Reader e decodifica i valori RESP
type Reader struct {
reader *bufio.Reader
}
// NewReader costruisce un Reader a partire da una sorgente di byte
func NewReader(rd io.Reader) *Reader {
return &Reader{reader: bufio.NewReader(rd)}
}
// readLine legge fino al terminatore CRLF e restituisce la riga senza di esso
func (r *Reader) readLine() ([]byte, error) {
var line []byte
for {
b, err := r.reader.ReadByte()
if err != nil {
return nil, err
}
line = append(line, b)
// ogni riga RESP termina con la sequenza \r\n
if len(line) >= 2 && line[len(line)-2] == '\r' {
break
}
}
return line[:len(line)-2], nil
}
// readInteger legge una riga e la interpreta come numero intero
func (r *Reader) readInteger() (int, error) {
line, err := r.readLine()
if err != nil {
return 0, err
}
return strconv.Atoi(string(line))
}
// Read decodifica il prossimo valore in base al suo primo byte
func (r *Reader) Read() (Value, error) {
dataType, err := r.reader.ReadByte()
if err != nil {
return Value{}, err
}
switch dataType {
case arrayType:
return r.readArray()
case bulkType:
return r.readBulk()
default:
// un client reale invia sempre array di bulk string: gli altri tipi
// vengono ignorati restituendo un valore vuoto
return Value{}, nil
}
}
// readArray legge un array contando i suoi elementi e decodificandoli a uno a uno
func (r *Reader) readArray() (Value, error) {
v := Value{typ: arrayType}
length, err := r.readInteger()
if err != nil {
return v, err
}
v.array = make([]Value, 0, length)
for i := 0; i < length; i++ {
item, err := r.Read()
if err != nil {
return v, err
}
v.array = append(v.array, item)
}
return v, nil
}
// readBulk legge una bulk string conoscendone in anticipo la lunghezza
func (r *Reader) readBulk() (Value, error) {
v := Value{typ: bulkType}
length, err := r.readInteger()
if err != nil {
return v, err
}
buffer := make([]byte, length)
if _, err := io.ReadFull(r.reader, buffer); err != nil {
return v, err
}
v.bulk = string(buffer)
// consuma il CRLF che chiude la bulk string
if _, err := r.readLine(); err != nil {
return v, err
}
return v, nil
}
// Marshal converte un Value nella sua rappresentazione binaria RESP
func (v Value) Marshal() []byte {
switch v.typ {
case arrayType:
return v.marshalArray()
case bulkType:
return v.marshalBulk()
case stringType:
return v.marshalString()
case integerType:
return v.marshalInteger()
case errorType:
return v.marshalError()
default:
return []byte{}
}
}
func (v Value) marshalString() []byte {
out := []byte{stringType}
out = append(out, v.str...)
return append(out, '\r', '\n')
}
func (v Value) marshalError() []byte {
out := []byte{errorType}
out = append(out, v.str...)
return append(out, '\r', '\n')
}
func (v Value) marshalInteger() []byte {
out := []byte{integerType}
out = append(out, strconv.Itoa(v.num)...)
return append(out, '\r', '\n')
}
func (v Value) marshalBulk() []byte {
if v.isNull {
// la bulk string nulla rappresenta l'assenza di un valore
return []byte("$-1\r\n")
}
out := []byte{bulkType}
out = append(out, strconv.Itoa(len(v.bulk))...)
out = append(out, '\r', '\n')
out = append(out, v.bulk...)
return append(out, '\r', '\n')
}
func (v Value) marshalArray() []byte {
out := []byte{arrayType}
out = append(out, strconv.Itoa(len(v.array))...)
out = append(out, '\r', '\n')
for _, item := range v.array {
out = append(out, item.Marshal()...)
}
return out
}
// Writer serializza i valori RESP verso una destinazione di byte
type Writer struct {
writer io.Writer
}
// NewWriter costruisce un Writer a partire da una destinazione di byte
func NewWriter(w io.Writer) *Writer {
return &Writer{writer: w}
}
// Write invia un Value già codificato in RESP
func (w *Writer) Write(v Value) error {
_, err := w.writer.Write(v.Marshal())
return err
}
Notiamo l'uso di io.ReadFull nella lettura delle bulk string: a differenza di una semplice Read, garantisce che venga letto esattamente il numero di byte dichiarato, anche se i dati arrivano frammentati in più pacchetti TCP. È un dettaglio facile da trascurare ma essenziale per la correttezza su connessioni reali.
Lo store in memoria
I dati vivono in memoria, ma più client possono leggerli e modificarli nello stesso momento attraverso goroutine diverse. Per evitare corse critiche proteggiamo ogni accesso con un sync.RWMutex, che consente letture concorrenti ma serializza le scritture.
Lo Store mantiene tre mappe distinte: una per le stringhe semplici (ognuna con la propria eventuale scadenza), una per gli hash e una per le liste. La scadenza è gestita con una strategia ibrida, come fa Redis stesso: pigra, perché una chiave scaduta viene rimossa al primo accesso successivo alla sua scadenza, e attiva, grazie a una goroutine che ogni secondo elimina le chiavi ormai obsolete liberando memoria anche se nessuno le richiede più.
package main
import (
"errors"
"strconv"
"sync"
"time"
)
// entry è il singolo valore di una stringa, con l'eventuale istante di scadenza
type entry struct {
value string
expireAt time.Time // valore zero: nessuna scadenza
}
// isExpired indica se la entry risulta scaduta rispetto all'istante attuale
func (e entry) isExpired() bool {
return !e.expireAt.IsZero() && time.Now().After(e.expireAt)
}
// Store è il contenitore in memoria, protetto da un mutex per l'accesso concorrente
type Store struct {
mu sync.RWMutex
data map[string]entry
hashes map[string]map[string]string
lists map[string][]string
}
// NewStore inizializza tutte le strutture dati interne
func NewStore() *Store {
return &Store{
data: make(map[string]entry),
hashes: make(map[string]map[string]string),
lists: make(map[string][]string),
}
}
// Set memorizza una stringa con un eventuale tempo di vita
func (s *Store) Set(key, value string, ttl time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
e := entry{value: value}
if ttl > 0 {
e.expireAt = time.Now().Add(ttl)
}
s.data[key] = e
}
// Get restituisce il valore di una chiave applicando la scadenza pigra
func (s *Store) Get(key string) (string, bool) {
s.mu.RLock()
e, ok := s.data[key]
s.mu.RUnlock()
if !ok {
return "", false
}
if e.isExpired() {
// scadenza pigra: la chiave viene rimossa al primo accesso utile
s.mu.Lock()
delete(s.data, key)
s.mu.Unlock()
return "", false
}
return e.value, true
}
// Delete rimuove una chiave e segnala se esisteva
func (s *Store) Delete(key string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.data[key]
delete(s.data, key)
return ok
}
// Expire imposta o aggiorna la scadenza di una chiave esistente
func (s *Store) Expire(key string, ttl time.Duration) bool {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.data[key]
if !ok || e.isExpired() {
return false
}
e.expireAt = time.Now().Add(ttl)
s.data[key] = e
return true
}
// TTL restituisce i secondi residui, oppure -1 (nessuna scadenza) o -2 (assente)
func (s *Store) TTL(key string) int {
s.mu.RLock()
defer s.mu.RUnlock()
e, ok := s.data[key]
if !ok {
return -2
}
if e.expireAt.IsZero() {
return -1
}
remaining := time.Until(e.expireAt)
if remaining <= 0 {
return -2
}
return int(remaining.Seconds())
}
// Incr incrementa di uno il valore intero associato a una chiave
func (s *Store) Incr(key string) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.data[key]
if !ok || e.isExpired() {
// chiave assente: si parte da zero e si imposta a uno
s.data[key] = entry{value: "1"}
return 1, nil
}
n, err := strconv.Atoi(e.value)
if err != nil {
return 0, errors.New("il valore non è un intero")
}
n++
e.value = strconv.Itoa(n)
s.data[key] = e
return n, nil
}
// HSet imposta il campo di un hash, creando l'hash se non esiste
func (s *Store) HSet(hash, field, value string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.hashes[hash] == nil {
s.hashes[hash] = make(map[string]string)
}
s.hashes[hash][field] = value
}
// HGet restituisce il valore di un campo di un hash
func (s *Store) HGet(hash, field string) (string, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
fields, ok := s.hashes[hash]
if !ok {
return "", false
}
value, ok := fields[field]
return value, ok
}
// HGetAll restituisce una copia di tutti i campi di un hash
func (s *Store) HGetAll(hash string) map[string]string {
s.mu.RLock()
defer s.mu.RUnlock()
// si restituisce una copia per non esporre la mappa interna agli altri client
result := make(map[string]string, len(s.hashes[hash]))
for k, v := range s.hashes[hash] {
result[k] = v
}
return result
}
// RPush accoda uno o più valori in fondo a una lista
func (s *Store) RPush(list string, values ...string) int {
s.mu.Lock()
defer s.mu.Unlock()
s.lists[list] = append(s.lists[list], values...)
return len(s.lists[list])
}
// LRange estrae una porzione di lista gestendo anche gli indici negativi
func (s *Store) LRange(list string, start, stop int) []string {
s.mu.RLock()
defer s.mu.RUnlock()
items := s.lists[list]
length := len(items)
if length == 0 {
return nil
}
// gli indici negativi sono relativi alla fine della lista
if start < 0 {
start += length
}
if stop < 0 {
stop += length
}
if start < 0 {
start = 0
}
if stop >= length {
stop = length - 1
}
if start > stop {
return nil
}
return items[start : stop+1]
}
// StartExpirationCycle rimuove periodicamente le chiavi scadute (scadenza attiva)
func (s *Store) StartExpirationCycle() {
ticker := time.NewTicker(time.Second)
for range ticker.C {
s.mu.Lock()
for key, e := range s.data {
if e.isExpired() {
delete(s.data, key)
}
}
s.mu.Unlock()
}
}
La funzione TTL rispetta la convenzione di Redis restituendo -2 quando la chiave non esiste e -1 quando esiste ma non ha scadenza. In HGetAll restituiamo deliberatamente una copia della mappa interna: passare il riferimento diretto esporrebbe la struttura condivisa a modifiche concorrenti fuori dal mutex.
I comandi
Ogni comando è una funzione con la stessa firma, raccolta in una mappa che associa il nome del comando alla sua implementazione. Questo approccio rende banale aggiungere nuovi comandi in futuro: basta scrivere la funzione e registrarla nella mappa.
Implementiamo i comandi più usati: PING ed ECHO per le verifiche di base; SET, GET, DEL ed EXISTS sulle stringhe; EXPIRE e TTL per la scadenza; INCR per i contatori atomici; HSET, HGET e HGETALL sugli hash; RPUSH e LRANGE sulle liste. Il comando SET supporta inoltre le opzioni EX e PX per impostare una scadenza contestualmente alla scrittura.
package main
import (
"strconv"
"strings"
"time"
)
// Handler è la firma comune di ogni funzione che esegue un comando
type Handler func(store *Store, args []Value) Value
// handlers associa il nome di ciascun comando alla relativa funzione
var handlers = map[string]Handler{
"PING": ping,
"ECHO": echo,
"SET": set,
"GET": get,
"DEL": del,
"EXISTS": exists,
"EXPIRE": expire,
"TTL": ttl,
"INCR": incr,
"HSET": hset,
"HGET": hget,
"HGETALL": hgetall,
"RPUSH": rpush,
"LRANGE": lrange,
}
func ping(store *Store, args []Value) Value {
if len(args) == 0 {
return Value{typ: stringType, str: "PONG"}
}
return Value{typ: bulkType, bulk: args[0].bulk}
}
func echo(store *Store, args []Value) Value {
if len(args) != 1 {
return Value{typ: errorType, str: "ERR numero di argomenti errato per 'echo'"}
}
return Value{typ: bulkType, bulk: args[0].bulk}
}
func set(store *Store, args []Value) Value {
if len(args) < 2 {
return Value{typ: errorType, str: "ERR numero di argomenti errato per 'set'"}
}
key := args[0].bulk
value := args[1].bulk
var ttl time.Duration
// gestisce le opzioni EX (secondi) e PX (millisecondi)
if len(args) >= 4 {
option := strings.ToUpper(args[2].bulk)
amount, err := strconv.Atoi(args[3].bulk)
if err != nil {
return Value{typ: errorType, str: "ERR il valore non è un intero"}
}
switch option {
case "EX":
ttl = time.Duration(amount) * time.Second
case "PX":
ttl = time.Duration(amount) * time.Millisecond
}
}
store.Set(key, value, ttl)
return Value{typ: stringType, str: "OK"}
}
func get(store *Store, args []Value) Value {
if len(args) != 1 {
return Value{typ: errorType, str: "ERR numero di argomenti errato per 'get'"}
}
value, ok := store.Get(args[0].bulk)
if !ok {
// chiave assente: si risponde con una bulk string nulla
return Value{typ: bulkType, isNull: true}
}
return Value{typ: bulkType, bulk: value}
}
func del(store *Store, args []Value) Value {
count := 0
for _, arg := range args {
if store.Delete(arg.bulk) {
count++
}
}
return Value{typ: integerType, num: count}
}
func exists(store *Store, args []Value) Value {
count := 0
for _, arg := range args {
if _, ok := store.Get(arg.bulk); ok {
count++
}
}
return Value{typ: integerType, num: count}
}
func expire(store *Store, args []Value) Value {
if len(args) != 2 {
return Value{typ: errorType, str: "ERR numero di argomenti errato per 'expire'"}
}
seconds, err := strconv.Atoi(args[1].bulk)
if err != nil {
return Value{typ: errorType, str: "ERR il valore non è un intero"}
}
if store.Expire(args[0].bulk, time.Duration(seconds)*time.Second) {
return Value{typ: integerType, num: 1}
}
return Value{typ: integerType, num: 0}
}
func ttl(store *Store, args []Value) Value {
if len(args) != 1 {
return Value{typ: errorType, str: "ERR numero di argomenti errato per 'ttl'"}
}
return Value{typ: integerType, num: store.TTL(args[0].bulk)}
}
func incr(store *Store, args []Value) Value {
if len(args) != 1 {
return Value{typ: errorType, str: "ERR numero di argomenti errato per 'incr'"}
}
n, err := store.Incr(args[0].bulk)
if err != nil {
return Value{typ: errorType, str: "ERR " + err.Error()}
}
return Value{typ: integerType, num: n}
}
func hset(store *Store, args []Value) Value {
if len(args) != 3 {
return Value{typ: errorType, str: "ERR numero di argomenti errato per 'hset'"}
}
store.HSet(args[0].bulk, args[1].bulk, args[2].bulk)
return Value{typ: integerType, num: 1}
}
func hget(store *Store, args []Value) Value {
if len(args) != 2 {
return Value{typ: errorType, str: "ERR numero di argomenti errato per 'hget'"}
}
value, ok := store.HGet(args[0].bulk, args[1].bulk)
if !ok {
return Value{typ: bulkType, isNull: true}
}
return Value{typ: bulkType, bulk: value}
}
func hgetall(store *Store, args []Value) Value {
if len(args) != 1 {
return Value{typ: errorType, str: "ERR numero di argomenti errato per 'hgetall'"}
}
fields := store.HGetAll(args[0].bulk)
result := Value{typ: arrayType}
// l'output alterna campo e valore, esattamente come fa Redis
for field, value := range fields {
result.array = append(result.array, Value{typ: bulkType, bulk: field})
result.array = append(result.array, Value{typ: bulkType, bulk: value})
}
return result
}
func rpush(store *Store, args []Value) Value {
if len(args) < 2 {
return Value{typ: errorType, str: "ERR numero di argomenti errato per 'rpush'"}
}
values := make([]string, 0, len(args)-1)
for _, arg := range args[1:] {
values = append(values, arg.bulk)
}
length := store.RPush(args[0].bulk, values...)
return Value{typ: integerType, num: length}
}
func lrange(store *Store, args []Value) Value {
if len(args) != 3 {
return Value{typ: errorType, str: "ERR numero di argomenti errato per 'lrange'"}
}
start, errStart := strconv.Atoi(args[1].bulk)
stop, errStop := strconv.Atoi(args[2].bulk)
if errStart != nil || errStop != nil {
return Value{typ: errorType, str: "ERR il valore non è un intero"}
}
items := store.LRange(args[0].bulk, start, stop)
result := Value{typ: arrayType}
for _, item := range items {
result.array = append(result.array, Value{typ: bulkType, bulk: item})
}
return result
}
Un dettaglio significativo riguarda la risposta a una chiave assente: comandi come GET e HGET restituiscono una bulk string nulla ($-1\r\n), che redis-cli visualizza come (nil). È diverso dal restituire una stringa vuota e permette al client di distinguere l'assenza di un valore da un valore effettivamente vuoto.
Persistenza con AOF
Finora i dati vivono solo in memoria e andrebbero persi a ogni riavvio. Redis offre due meccanismi di persistenza; il più semplice da implementare è l'Append Only File (AOF), che registra su disco ogni comando di scrittura nello stesso formato RESP usato in rete. All'avvio il server rilegge il file e riesegue i comandi salvati, ricostruendo esattamente lo stato precedente.
package main
import (
"bufio"
"os"
"sync"
)
// Aof gestisce la persistenza append-only: ogni comando di scrittura viene
// registrato su file e rieseguito all'avvio per ricostruire lo stato
type Aof struct {
file *os.File
mu sync.Mutex
}
// NewAof apre (o crea) il file di log in lettura e scrittura
func NewAof(path string) (*Aof, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0666)
if err != nil {
return nil, err
}
return &Aof{file: file}, nil
}
// Write accoda al file un comando già codificato in RESP
func (a *Aof) Write(v Value) error {
a.mu.Lock()
defer a.mu.Unlock()
_, err := a.file.Write(v.Marshal())
return err
}
// Read rilegge tutti i comandi salvati e li passa alla callback fornita
func (a *Aof) Read(callback func(v Value)) error {
a.mu.Lock()
defer a.mu.Unlock()
if _, err := a.file.Seek(0, 0); err != nil {
return err
}
reader := NewReader(bufio.NewReader(a.file))
for {
value, err := reader.Read()
if err != nil {
break // fine del file raggiunta
}
callback(value)
}
return nil
}
// Close chiude il file sottostante
func (a *Aof) Close() error {
a.mu.Lock()
defer a.mu.Unlock()
return a.file.Close()
}
Il bello di questo approccio è che il file AOF non richiede un formato di serializzazione dedicato: riutilizziamo lo stesso Reader già scritto per la rete. Per applicazioni reali si aggiungerebbe una chiamata periodica a file.Sync() per forzare la scrittura su disco e una procedura di compattazione per evitare che il file cresca indefinitamente, ma il principio resta questo.
Il server TCP
Manca l'ultimo tassello: il punto di ingresso che mette in ascolto un socket TCP e gestisce le connessioni. All'avvio creiamo lo store, lanciamo la goroutine di scadenza attiva, apriamo il file AOF e rieseguiamo i comandi salvati. Poi entriamo nel ciclo di Accept: ogni nuova connessione viene affidata a una goroutine dedicata, così che client diversi non si blocchino a vicenda.
All'interno della singola connessione leggiamo un comando alla volta, ne ricaviamo il nome (normalizzato in maiuscolo) e gli argomenti, cerchiamo l'handler corrispondente e gli scriviamo la risposta. I soli comandi che modificano lo stato vengono registrati sul file AOF prima di rispondere al client.
package main
import (
"fmt"
"net"
"strings"
)
// writeCommands elenca i comandi che modificano lo stato e vanno persistiti
var writeCommands = map[string]bool{
"SET": true,
"DEL": true,
"EXPIRE": true,
"INCR": true,
"HSET": true,
"RPUSH": true,
}
func main() {
store := NewStore()
go store.StartExpirationCycle()
// apertura del file AOF e ricostruzione dello stato precedente
aof, err := NewAof("database.aof")
if err != nil {
fmt.Println("Impossibile aprire il file AOF:", err)
return
}
defer aof.Close()
aof.Read(func(v Value) {
if v.typ != arrayType || len(v.array) == 0 {
return
}
command := strings.ToUpper(v.array[0].bulk)
if handler, ok := handlers[command]; ok {
handler(store, v.array[1:])
}
})
listener, err := net.Listen("tcp", ":6380")
if err != nil {
fmt.Println("Impossibile mettersi in ascolto:", err)
return
}
defer listener.Close()
fmt.Println("Server in ascolto sulla porta 6380")
for {
conn, err := listener.Accept()
if err != nil {
fmt.Println("Errore di connessione:", err)
continue
}
// ogni client viene gestito da una goroutine dedicata
go handleConnection(conn, store, aof)
}
}
func handleConnection(conn net.Conn, store *Store, aof *Aof) {
defer conn.Close()
reader := NewReader(conn)
writer := NewWriter(conn)
for {
value, err := reader.Read()
if err != nil {
// il client ha chiuso la connessione o ha inviato dati malformati
return
}
// un comando valido è sempre un array di bulk string non vuoto
if value.typ != arrayType || len(value.array) == 0 {
writer.Write(Value{typ: errorType, str: "ERR richiesta non valida"})
continue
}
// il primo elemento è il nome del comando, normalizzato in maiuscolo
command := strings.ToUpper(value.array[0].bulk)
args := value.array[1:]
handler, ok := handlers[command]
if !ok {
writer.Write(Value{typ: errorType, str: fmt.Sprintf("ERR comando sconosciuto '%s'", command)})
continue
}
// i comandi di scrittura vengono registrati prima di rispondere
if writeCommands[command] {
aof.Write(value)
}
writer.Write(handler(store, args))
}
}
Usiamo deliberatamente la porta 6380 anziché la 6379 di default, così da poter eseguire il nostro server senza entrare in conflitto con un'eventuale istanza reale di Redis già attiva sulla macchina.
Provare il server
Compiliamo ed eseguiamo il server con i consueti comandi di Go:
go build -o goredis .
./goredis
A questo punto possiamo collegarci con il client ufficiale redis-cli, lo stesso che si userebbe con un Redis vero, indicando la porta del nostro server. Tutti i comandi implementati rispondono correttamente:
$ redis-cli -p 6380
127.0.0.1:6380> PING
PONG
127.0.0.1:6380> SET nome "Gabriele"
OK
127.0.0.1:6380> GET nome
"Gabriele"
127.0.0.1:6380> SET sessione token EX 60
OK
127.0.0.1:6380> TTL sessione
(integer) 60
127.0.0.1:6380> INCR contatore
(integer) 1
127.0.0.1:6380> INCR contatore
(integer) 2
127.0.0.1:6380> RPUSH colori rosso verde blu
(integer) 3
127.0.0.1:6380> LRANGE colori 0 -1
1) "rosso"
2) "verde"
3) "blu"
127.0.0.1:6380> HSET utente nome Gabriele
(integer) 1
127.0.0.1:6380> HGET utente nome
"Gabriele"
127.0.0.1:6380> GET inesistente
(nil)
Poiché il nostro server parla RESP, funziona anche con qualunque libreria client Redis esistente: una stessa applicazione potrebbe puntare indifferentemente al Redis reale o alla nostra implementazione semplicemente cambiando la porta di connessione.
Conclusioni
Con poche centinaia di righe di Go abbiamo costruito un server in memoria concorrente, compatibile con il protocollo RESP e interrogabile con gli strumenti standard dell'ecosistema Redis. Lungo il percorso abbiamo affrontato la lettura di un protocollo binario su socket TCP, la gestione dello stato condiviso tra goroutine tramite mutex, le strategie di scadenza pigra e attiva delle chiavi e una forma elementare ma funzionante di persistenza su disco.
Da qui le direzioni di estensione sono molte: aggiungere nuovi tipi di dato come i set e i sorted set, implementare il pattern publish/subscribe, introdurre le transazioni con MULTI ed EXEC, oppure realizzare la persistenza a snapshot in stile RDB. Ognuna di queste estensioni si innesta naturalmente sull'architettura modulare che abbiamo definito: nuovi comandi nella mappa degli handler, nuove strutture nello store. È il modo migliore per continuare a esplorare il funzionamento interno di uno dei mattoni più usati dell'infrastruttura moderna.