Crea il tuo Redis in Node.js
Redis è uno dei database in memoria più diffusi al mondo: lo si usa come cache, come broker di messaggi, come store di sessioni e per molti altri scopi. Dietro la sua apparente semplicità si nasconde un'architettura elegante basata su un server TCP, un protocollo di serializzazione testuale e una collezione di strutture dati in memoria. In questo articolo costruiremo da zero una versione minimale ma funzionante di Redis utilizzando soltanto i moduli nativi di Node.js, senza alcuna dipendenza esterna.
L'obiettivo non è competere con Redis in termini di prestazioni o completezza, ma comprendere a fondo come funziona un server di questo tipo: come accetta connessioni, come interpreta i comandi inviati dai client e come gestisce i dati e la loro scadenza. Al termine avremo un server compatibile con redis-cli in grado di rispondere a comandi come PING, SET, GET, DEL, EXPIRE, TTL e INCR.
Che cosa costruiremo
Il nostro mini-Redis sarà composto da quattro componenti principali, che svilupperemo in modo incrementale:
- Un server TCP basato sul modulo
netche accetta connessioni dai client. - Un parser del protocollo RESP, il formato testuale con cui i client Redis comunicano con il server.
- Uno store in memoria che conserva le coppie chiave-valore e gestisce la scadenza delle chiavi.
- Un dispatcher dei comandi che instrada ogni richiesta verso la funzione corretta.
Useremo Node.js senza framework né librerie: tutto ciò che ci serve è già presente nella libreria standard. Creiamo una cartella di progetto e un singolo file server.js in cui aggiungeremo via via il codice.
Il protocollo RESP
RESP, acronimo di REdis Serialization Protocol, è il protocollo che i client e il server Redis usano per scambiarsi messaggi. È un protocollo testuale, semplice da leggere e da implementare, in cui il primo byte di ogni messaggio ne indica il tipo. I tipi fondamentali sono cinque:
- Simple String: inizia con
+, ad esempio+OK\r\n. - Error: inizia con
-, ad esempio-ERR unknown command\r\n. - Integer: inizia con
:, ad esempio:1000\r\n. - Bulk String: inizia con
$seguito dalla lunghezza, ad esempio$6\r\nfoobar\r\n. - Array: inizia con
*seguito dal numero di elementi, ad esempio*2\r\n$4\r\nECHO\r\n$5\r\nhello\r\n.
Ogni elemento è terminato dalla sequenza CRLF, cioè i due caratteri \r\n. Un dettaglio importante è che i client inviano sempre i comandi come array di bulk string. Quando dal terminale digitiamo SET foo bar, redis-cli traduce questo in:
*3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n
Il nostro compito sarà quindi leggere questi byte, ricostruire l'array di stringhe e infine produrre una risposta nello stesso formato. Le risposte invece possono essere di qualunque tipo: una Simple String per confermare un'operazione, una Bulk String per restituire un valore, un Integer per un conteggio, un Error in caso di problemi.
Il server TCP di base
Cominciamo dal cuore della comunicazione: il server TCP. Il modulo net di Node.js permette di creare un server di rete in pochissime righe. Ogni volta che un client si connette riceviamo un oggetto socket su cui possiamo ascoltare l'evento data per i byte in arrivo e su cui possiamo scrivere le risposte.
const net = require('net');
// Crea un server TCP: la callback viene invocata a ogni nuova connessione
const server = net.createServer((socket) => {
// Stampa l'indirizzo del client che si è appena connesso
console.log('Nuova connessione da', socket.remoteAddress);
socket.on('data', (chunk) => {
// Per ora ci limitiamo a stampare i byte ricevuti
console.log('Ricevuto:', chunk.toString());
// Rispondiamo sempre con un PONG in formato RESP
socket.write('+PONG\r\n');
});
socket.on('error', (error) => {
console.error('Errore sul socket:', error.message);
});
});
// Usiamo la porta 6380 per non entrare in conflitto con un eventuale Redis reale
const PORT = 6380;
server.listen(PORT, () => {
console.log(`Mini Redis in ascolto sulla porta ${PORT}`);
});
Avviando questo file con node server.js e collegandoci con redis-cli -p 6380, qualunque comando digitato riceverà come risposta PONG. È un punto di partenza grezzo ma dimostra che la connessione e lo scambio di byte funzionano. Il problema evidente è che non interpretiamo affatto i comandi: dobbiamo costruire un vero parser RESP.
Parsing del protocollo RESP
Un aspetto cruciale, spesso trascurato in implementazioni didattiche, è che TCP è un protocollo a flusso di byte: i dati possono arrivare frammentati in più eventi data, oppure più comandi possono arrivare insieme nello stesso evento. Per questo motivo il parser deve mantenere un buffer interno, accumulare i dati in arrivo ed estrarre soltanto i comandi completi, lasciando nel buffer eventuali frammenti incompleti in attesa del resto.
Implementiamo il parser come una classe. Il metodo feed aggiunge i nuovi byte al buffer, mentre parse estrae tutti i comandi completi disponibili. Ogni metodo di parsing restituisce un oggetto con il valore decodificato e il nuovo offset, oppure null se i dati non sono ancora completi.
class RESPParser {
constructor() {
// Buffer che accumula i byte ricevuti finché non formano comandi completi
this.buffer = Buffer.alloc(0);
}
// Aggiunge un nuovo blocco di dati al buffer interno
feed(chunk) {
this.buffer = Buffer.concat([this.buffer, chunk]);
}
// Cerca la posizione del prossimo CRLF a partire dall'indice "start"
findCRLF(start) {
for (let i = start; i < this.buffer.length - 1; i++) {
// 0x0d è il carattere CR, 0x0a è il carattere LF
if (this.buffer[i] === 0x0d && this.buffer[i + 1] === 0x0a) {
return i;
}
}
return -1;
}
// Estrae tutti i comandi completi presenti nel buffer
parse() {
const commands = [];
let offset = 0;
while (offset < this.buffer.length) {
const result = this.parseValue(offset);
// Se i dati sono incompleti interrompiamo e aspettiamo altro
if (result === null) {
break;
}
commands.push(result.value);
offset = result.offset;
}
// Conserviamo nel buffer solo la parte non ancora consumata
this.buffer = this.buffer.subarray(offset);
return commands;
}
}
Il metodo parseValue esamina il primo byte per capire il tipo del valore e delega al metodo specializzato. Per i nostri scopi gestiamo gli array (*) e le bulk string ($), che sono i tipi inviati dai client. In più aggiungiamo il supporto ai cosiddetti comandi inline, cioè comandi inviati come semplice testo separato da spazi: questo torna utile quando si fanno prove con telnet o con netcat.
// Aggiungiamo questi metodi alla classe RESPParser
parseValue(offset) {
if (offset >= this.buffer.length) {
return null;
}
// Il primo byte indica il tipo del valore RESP
const type = String.fromCharCode(this.buffer[offset]);
if (type === '*') {
return this.parseArray(offset);
}
if (type === '$') {
return this.parseBulkString(offset);
}
// Qualunque altro byte viene trattato come comando inline (testo semplice)
return this.parseInline(offset);
}
parseArray(offset) {
const crlf = this.findCRLF(offset);
if (crlf === -1) {
return null;
}
// Il numero che segue l'asterisco indica quanti elementi contiene l'array
const count = parseInt(this.buffer.toString('utf8', offset + 1, crlf), 10);
let cursor = crlf + 2;
const items = [];
for (let i = 0; i < count; i++) {
const result = this.parseValue(cursor);
// Se anche un solo elemento è incompleto attendiamo altri dati
if (result === null) {
return null;
}
items.push(result.value);
cursor = result.offset;
}
return { value: items, offset: cursor };
}
parseBulkString(offset) {
const crlf = this.findCRLF(offset);
if (crlf === -1) {
return null;
}
// Il numero che segue il simbolo del dollaro è la lunghezza della stringa
const length = parseInt(this.buffer.toString('utf8', offset + 1, crlf), 10);
// Una lunghezza pari a -1 rappresenta una bulk string nulla
if (length === -1) {
return { value: null, offset: crlf + 2 };
}
const dataStart = crlf + 2;
const dataEnd = dataStart + length;
// Verifichiamo che siano arrivati tutti i byte della stringa più il CRLF finale
if (this.buffer.length < dataEnd + 2) {
return null;
}
const value = this.buffer.toString('utf8', dataStart, dataEnd);
return { value, offset: dataEnd + 2 };
}
parseInline(offset) {
const crlf = this.findCRLF(offset);
if (crlf === -1) {
return null;
}
const line = this.buffer.toString('utf8', offset, crlf).trim();
// Spezziamo la riga sugli spazi per ottenere il comando e i suoi argomenti
const parts = line.length > 0 ? line.split(/\s+/) : [];
return { value: parts, offset: crlf + 2 };
}
Con questo parser siamo in grado di trasformare qualunque flusso di byte RESP in un array di stringhe del tipo ['SET', 'foo', 'bar'], pronto per essere instradato verso il comando corretto. La gestione del buffer parziale garantisce che il parser sia robusto anche quando i dati arrivano spezzati su più pacchetti TCP.
La serializzazione delle risposte
Prima di occuparci dei comandi, definiamo un piccolo helper per costruire le risposte nel formato RESP. Avere funzioni dedicate per ciascun tipo rende il codice dei comandi molto più leggibile, perché non dovremo concatenare manualmente caratteri di controllo e CRLF.
const RESP = {
// Simple String, usata per conferme come +OK
simpleString(value) {
return `+${value}\r\n`;
},
// Error, usato per segnalare condizioni di errore
error(message) {
return `-${message}\r\n`;
},
// Integer, usato per conteggi e contatori
integer(value) {
return `:${value}\r\n`;
},
// Bulk String, usata per restituire valori arbitrari
bulkString(value) {
// Il valore nullo viene rappresentato con la stringa speciale $-1
if (value === null) {
return '$-1\r\n';
}
// Calcoliamo la lunghezza in byte per gestire correttamente l'UTF-8
return `$${Buffer.byteLength(value)}\r\n${value}\r\n`;
},
// Array di bulk string, usato per risposte composte da più valori
array(values) {
if (values === null) {
return '*-1\r\n';
}
let result = `*${values.length}\r\n`;
for (const value of values) {
result += RESP.bulkString(value);
}
return result;
}
};
Notiamo che per la lunghezza delle bulk string usiamo Buffer.byteLength e non value.length: i due valori coincidono solo per il testo ASCII, ma divergono in presenza di caratteri accentati o di altri alfabeti, perché RESP misura la lunghezza in byte e non in caratteri.
Lo store in memoria
Il prossimo componente è lo store, ossia la struttura dati che conserva le coppie chiave-valore. Useremo due oggetti Map: uno per i valori veri e propri e uno per i timestamp di scadenza delle chiavi. Separare le due mappe semplifica la logica di scadenza e ricalca da vicino il modello mentale di Redis, in cui la scadenza è un'informazione associata alla chiave ma distinta dal valore.
Adottiamo una strategia di scadenza pigra, detta lazy expiration: invece di cancellare attivamente le chiavi scadute con un timer, le rimuoviamo nel momento in cui vengono lette o interrogate. È esattamente uno dei due meccanismi che usa Redis stesso e ha il vantaggio di essere semplice ed efficiente per la maggior parte dei carichi di lavoro.
class Store {
constructor() {
// Mappa che associa ogni chiave al proprio valore
this.data = new Map();
// Mappa che associa una chiave al timestamp di scadenza in millisecondi
this.expirations = new Map();
}
// Controlla se una chiave è scaduta e in tal caso la rimuove
isExpired(key) {
const expireAt = this.expirations.get(key);
if (expireAt === undefined) {
return false;
}
if (Date.now() >= expireAt) {
this.data.delete(key);
this.expirations.delete(key);
return true;
}
return false;
}
// Restituisce il valore di una chiave oppure null se assente o scaduta
get(key) {
if (this.isExpired(key)) {
return null;
}
return this.data.has(key) ? this.data.get(key) : null;
}
// Imposta il valore di una chiave azzerando una eventuale scadenza precedente
set(key, value) {
this.data.set(key, value);
this.expirations.delete(key);
}
// Elimina una chiave restituendo true se esisteva ed era valida
delete(key) {
const existed = this.data.has(key) && !this.isExpired(key);
this.data.delete(key);
this.expirations.delete(key);
return existed;
}
// Verifica l'esistenza di una chiave non scaduta
has(key) {
if (this.isExpired(key)) {
return false;
}
return this.data.has(key);
}
// Associa una scadenza espressa in millisecondi a una chiave esistente
setExpiration(key, ttlMs) {
if (!this.has(key)) {
return false;
}
this.expirations.set(key, Date.now() + ttlMs);
return true;
}
// Restituisce il tempo residuo in secondi secondo la semantica di Redis
getTTL(key) {
if (!this.has(key)) {
return -2; // la chiave non esiste
}
const expireAt = this.expirations.get(key);
if (expireAt === undefined) {
return -1; // la chiave esiste ma non ha una scadenza impostata
}
return Math.ceil((expireAt - Date.now()) / 1000);
}
}
I valori di ritorno di getTTL seguono fedelmente la convenzione di Redis: -2 indica che la chiave non esiste, -1 indica che la chiave esiste ma non ha scadenza, mentre un numero positivo rappresenta i secondi rimanenti. Rispettare queste convenzioni è ciò che rende il nostro server compatibile con i client esistenti.
I comandi
Siamo finalmente pronti a implementare i comandi veri e propri. Definiamo un oggetto commands in cui ogni chiave è il nome di un comando in maiuscolo e ogni valore è una funzione che riceve gli argomenti e restituisce una risposta RESP già serializzata. Questo approccio a tabella rende banale aggiungere nuovi comandi in futuro.
Partiamo dai comandi più semplici. PING risponde PONG oppure restituisce il messaggio passato come argomento; ECHO restituisce esattamente la stringa ricevuta.
const store = new Store();
const commands = {
PING(args) {
// Se viene fornito un argomento lo restituiamo come bulk string
if (args.length > 0) {
return RESP.bulkString(args[0]);
}
return RESP.simpleString('PONG');
},
ECHO(args) {
if (args.length === 0) {
return RESP.error("ERR wrong number of arguments for 'echo' command");
}
return RESP.bulkString(args[0]);
}
};
Passiamo ora ai comandi che lavorano davvero sui dati. SET memorizza una coppia chiave-valore e supporta le opzioni EX e PX per impostare una scadenza, rispettivamente in secondi e in millisecondi. GET recupera un valore, DEL elimina una o più chiavi restituendo il numero di chiavi effettivamente rimosse, ed EXISTS conta quante delle chiavi indicate esistono.
// Aggiungiamo questi metodi all'oggetto commands
SET(args) {
if (args.length < 2) {
return RESP.error("ERR wrong number of arguments for 'set' command");
}
const [key, value, ...options] = args;
store.set(key, value);
// Interpretiamo le opzioni di scadenza EX (secondi) e PX (millisecondi)
for (let i = 0; i < options.length; i++) {
const option = options[i].toUpperCase();
if (option === 'EX' && options[i + 1]) {
store.setExpiration(key, parseInt(options[i + 1], 10) * 1000);
}
if (option === 'PX' && options[i + 1]) {
store.setExpiration(key, parseInt(options[i + 1], 10));
}
}
return RESP.simpleString('OK');
},
GET(args) {
if (args.length === 0) {
return RESP.error("ERR wrong number of arguments for 'get' command");
}
return RESP.bulkString(store.get(args[0]));
},
DEL(args) {
let count = 0;
for (const key of args) {
if (store.delete(key)) {
count++;
}
}
return RESP.integer(count);
},
EXISTS(args) {
let count = 0;
for (const key of args) {
if (store.has(key)) {
count++;
}
}
return RESP.integer(count);
}
La scadenza delle chiavi
La gestione della scadenza è una delle caratteristiche più apprezzate di Redis, fondamentale quando lo si usa come cache. Implementiamo EXPIRE, che associa una scadenza in secondi a una chiave già esistente, e TTL, che restituisce il tempo residuo. Grazie alla logica già presente nello store, queste funzioni sono molto compatte.
// Aggiungiamo questi metodi all'oggetto commands
EXPIRE(args) {
if (args.length < 2) {
return RESP.error("ERR wrong number of arguments for 'expire' command");
}
const seconds = parseInt(args[1], 10);
// setExpiration restituisce false se la chiave non esiste
const success = store.setExpiration(args[0], seconds * 1000);
return RESP.integer(success ? 1 : 0);
},
TTL(args) {
if (args.length === 0) {
return RESP.error("ERR wrong number of arguments for 'ttl' command");
}
return RESP.integer(store.getTTL(args[0]));
}
Con questi due comandi possiamo impostare una chiave con SET session abc123, applicarle una scadenza con EXPIRE session 10 e verificare il tempo residuo con TTL session. Trascorsi dieci secondi, una successiva GET session restituirà un valore nullo perché la scadenza pigra avrà rimosso la chiave al momento della lettura.
I contatori atomici
Un caso d'uso classico di Redis è il conteggio: contatori di visite, di like, di tentativi di accesso. I comandi INCR e DECR incrementano e decrementano un valore numerico in modo atomico, trattando una chiave assente come se valesse zero. Poiché Node.js esegue il nostro codice su un singolo thread, l'atomicità è garantita gratuitamente: nessun altro comando può interporsi a metà di un incremento.
// Aggiungiamo questi metodi all'oggetto commands
INCR(args) {
if (args.length === 0) {
return RESP.error("ERR wrong number of arguments for 'incr' command");
}
const key = args[0];
const current = store.get(key);
// Una chiave assente viene considerata pari a zero
const number = current === null ? 0 : parseInt(current, 10);
if (Number.isNaN(number)) {
return RESP.error('ERR value is not an integer or out of range');
}
const next = number + 1;
// I valori in Redis sono sempre stringhe, anche quando rappresentano numeri
store.set(key, String(next));
return RESP.integer(next);
},
DECR(args) {
if (args.length === 0) {
return RESP.error("ERR wrong number of arguments for 'decr' command");
}
const key = args[0];
const current = store.get(key);
const number = current === null ? 0 : parseInt(current, 10);
if (Number.isNaN(number)) {
return RESP.error('ERR value is not an integer or out of range');
}
const next = number - 1;
store.set(key, String(next));
return RESP.integer(next);
}
Da notare che, prima di incrementare, controlliamo che il valore corrente sia effettivamente numerico. Se la chiave contiene una stringa non convertibile in numero, restituiamo lo stesso errore che restituirebbe Redis, mantenendo così la compatibilità nel comportamento oltre che nel formato.
Il dispatcher dei comandi
Tutti i comandi sono pronti, ma manca il collante che, dato un array di stringhe come ['SET', 'foo', 'bar'], individui la funzione corretta e la invochi con gli argomenti giusti. Questo è il compito del dispatcher. Il nome del comando viene normalizzato in maiuscolo, così da accettare indifferentemente set, SET o Set, esattamente come fa Redis.
function dispatch(parts) {
// Ignoriamo gli input vuoti o non validi
if (!Array.isArray(parts) || parts.length === 0) {
return null;
}
// Il primo elemento è il nome del comando, normalizzato in maiuscolo
const name = String(parts[0]).toUpperCase();
const handler = commands[name];
if (!handler) {
return RESP.error(`ERR unknown command '${parts[0]}'`);
}
// Passiamo all'handler tutti gli elementi successivi come argomenti
return handler(parts.slice(1));
}
Mettere tutto insieme
Possiamo ora riscrivere il server TCP collegandolo al parser e al dispatcher. Per ogni connessione creiamo un'istanza dedicata di RESPParser, in modo che lo stato del buffer non venga condiviso tra client diversi. A ogni evento data alimentiamo il parser, estraiamo i comandi completi, li smistiamo e scriviamo le risposte sul socket.
const server = net.createServer((socket) => {
// Ogni connessione ha il proprio parser con il proprio buffer
const parser = new RESPParser();
socket.on('data', (chunk) => {
parser.feed(chunk);
const messages = parser.parse();
for (const parts of messages) {
const reply = dispatch(parts);
// Scriviamo la risposta solo se il dispatcher ne ha prodotta una
if (reply !== null) {
socket.write(reply);
}
}
});
socket.on('error', (error) => {
console.error('Errore sul socket:', error.message);
});
});
const PORT = 6380;
server.listen(PORT, () => {
console.log(`Mini Redis in ascolto sulla porta ${PORT}`);
});
Unendo i pezzi nell'ordine in cui li abbiamo presentati (helper RESP, classe RESPParser, classe Store, oggetto commands, funzione dispatch e infine il server) otteniamo un file server.js completo e autosufficiente, privo di qualunque dipendenza esterna.
Provare il server con redis-cli
Avviamo il server e proviamolo con il client ufficiale di Redis, indicandogli la porta su cui siamo in ascolto. Se non abbiamo redis-cli a disposizione, qualunque client compatibile o anche un semplice telnet funzionerà altrettanto bene grazie al supporto dei comandi inline.
# Avvia il server in un terminale
node server.js
# In un altro terminale connettiti specificando la porta
redis-cli -p 6380
A questo punto possiamo eseguire una sequenza di comandi per verificare che tutto funzioni come previsto:
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> EXISTS nome
(integer) 1
127.0.0.1:6380> INCR visite
(integer) 1
127.0.0.1:6380> INCR visite
(integer) 2
127.0.0.1:6380> SET token xyz EX 5
OK
127.0.0.1:6380> TTL token
(integer) 5
127.0.0.1:6380> DEL nome
(integer) 1
127.0.0.1:6380> GET nome
(nil)
Il fatto che redis-cli interpreti correttamente le nostre risposte, mostrando (integer) per i contatori e (nil) per i valori assenti, conferma che la nostra implementazione del protocollo RESP è corretta. Stiamo davvero parlando la stessa lingua di Redis.
Limiti e possibili estensioni
La versione che abbiamo costruito è volutamente minimale e presenta diversi limiti rispetto al Redis reale, che è il risultato di anni di sviluppo e ottimizzazione. Tra le differenze più rilevanti vale la pena citare le seguenti.
- Persistenza: i nostri dati vivono solo in memoria e vengono persi al riavvio. Redis offre meccanismi di salvataggio su disco come RDB e AOF.
- Strutture dati: gestiamo soltanto stringhe, mentre Redis supporta liste, set, hash, sorted set, stream e molto altro.
- Scadenza attiva: usiamo solo la scadenza pigra, mentre Redis affianca a essa un campionamento periodico per liberare memoria anche dalle chiavi mai più lette.
- Replica e cluster: non prevediamo alcuna forma di replica, partizionamento o alta disponibilità.
Proprio questi limiti rappresentano altrettanti spunti per estendere il progetto. Si può aggiungere il comando KEYS per elencare le chiavi, implementare le liste con LPUSH e RPUSH, introdurre gli hash con HSET e HGET, oppure sperimentare la persistenza serializzando periodicamente lo store su file. Ognuna di queste aggiunte si innesta in modo naturale sull'architettura a tabella di comandi che abbiamo predisposto.
Conclusione
Partendo da zero e usando soltanto i moduli nativi di Node.js, abbiamo costruito un server in memoria compatibile con il protocollo di Redis. Lungo il percorso abbiamo affrontato temi tutt'altro che banali: la gestione di un flusso di byte TCP potenzialmente frammentato, l'implementazione di un parser stateful per un protocollo testuale, la separazione netta tra trasporto, parsing, logica dei comandi e archiviazione dei dati.
Al di là del risultato finale, il valore di un esercizio come questo sta nella comprensione che ne deriva. Dopo averlo svolto, strumenti come Redis smettono di essere scatole nere e diventano sistemi di cui intuiamo il funzionamento interno. È un esercizio che consiglio di affrontare anche con altri protocolli e altri server: ricostruire qualcosa da zero, anche in forma semplificata, resta uno dei modi più efficaci per imparare davvero come funziona.