Crea il tuo Redis in PHP
Redis è uno dei database in memoria più diffusi: viene usato come cache, come broker di messaggi, come store di sessioni e molto altro. Dietro la sua apparente semplicità si nascondono alcuni concetti fondamentali e sorprendentemente accessibili: un server TCP che resta in ascolto, un protocollo testuale ben definito per la comunicazione client-server e un dizionario in memoria che associa chiavi a valori tipizzati. In questo articolo costruiremo da zero, in PHP, un clone minimale ma funzionante di Redis, compatibile con il client ufficiale redis-cli.
L'obiettivo non è competere con l'originale, scritto in C e ottimizzato in modo maniacale, ma capire come funziona internamente un sistema del genere implementandone i pezzi essenziali: il parsing del protocollo, la serializzazione delle risposte, il motore di memorizzazione con scadenza delle chiavi, il dispatcher dei comandi e l'event loop del server.
Cos'è Redis e come funziona
Redis è un server che mantiene i dati in RAM e li espone attraverso una connessione di rete. I client si collegano via TCP (per impostazione predefinita sulla porta 6379), inviano comandi codificati secondo un protocollo specifico e ricevono risposte codificate allo stesso modo. Internamente Redis gestisce un keyspace, ovvero un grande dizionario in cui ogni chiave punta a un valore che può essere di tipi diversi: stringhe, liste, hash, insiemi, insiemi ordinati e altri ancora.
Per il nostro clone implementeremo i tre tipi più comuni (stringhe, liste e hash) insieme a un meccanismo di scadenza delle chiavi. Tutto il resto poggia su due pilastri: il protocollo di comunicazione e l'architettura del server.
Il protocollo RESP
La comunicazione avviene tramite RESP, acronimo di REdis Serialization Protocol. È un protocollo testuale, semplice da leggere e da implementare, in cui ogni messaggio inizia con un carattere che ne identifica il tipo ed è terminato dalla sequenza \r\n (CRLF). I tipi fondamentali sono cinque.
| Tipo | Prefisso | Esempio |
|---|---|---|
| Simple String | + |
+OK\r\n |
| Error | - |
-ERR unknown command\r\n |
| Integer | : |
:42\r\n |
| Bulk String | $ |
$5\r\nhello\r\n |
| Array | * |
*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n |
La Bulk String è una stringa binary-safe preceduta dalla sua lunghezza in byte: questo permette di trasportare anche dati che contengono il carattere CRLF. Il valore $-1\r\n rappresenta una stringa nulla, mentre *-1\r\n rappresenta un array nullo.
Il punto cruciale è il modo in cui i client inviano i comandi. Un comando come SET name Mario viene trasmesso come un array di Bulk String, ognuna corrispondente a un argomento:
*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$5\r\nMario\r\n
Tradotto: un array di tre elementi, in cui il primo è la stringa SET di tre byte, il secondo è name di quattro byte e il terzo è Mario di cinque byte. Il nostro parser dovrà ricostruire da questo flusso di byte l'elenco degli argomenti del comando.
Esiste anche un protocollo inline più semplice, in cui un comando è semplicemente una riga di testo con gli argomenti separati da spazi e terminata da CRLF. È quello che si ottiene scrivendo a mano in una sessione telnet, e lo supporteremo come fallback.
Architettura del progetto
Suddivideremo il codice in componenti dalle responsabilità ben distinte, in modo che ognuno faccia una cosa sola e la faccia bene:
RespParser: analizza il flusso di byte in arrivo e ne estrae i comandi.RespSerializer: trasforma i valori PHP nella loro rappresentazione RESP da inviare al client.Store: il motore di memorizzazione in memoria, con gestione della scadenza delle chiavi.CommandDispatcher: riceve gli argomenti di un comando, lo esegue contro loStoree produce la risposta.Server: il server TCP con event loop non bloccante che orchestra tutto il resto.
Il codice richiede PHP 8.1 o successivo, perché useremo le proprietà readonly, la promozione dei parametri del costruttore e l'espressione match.
Il parser RESP
Il parser è la parte più delicata, perché deve fare i conti con la natura del TCP: i dati arrivano come un flusso continuo di byte, senza garanzie sui confini dei messaggi. Un singolo comando può arrivare spezzato in più letture, oppure più comandi possono arrivare insieme nella stessa lettura. Per questo il nostro metodo parse() lavora su un buffer e restituisce sia il valore decodificato sia il numero di byte consumati, oppure null se i dati a disposizione non bastano a comporre un messaggio completo.
<?php
declare(strict_types=1);
final class RespParser
{
private const CRLF = "\r\n";
/**
* Analizza un singolo valore RESP a partire da una posizione del buffer.
*
* @return array{0: mixed, 1: int}|null La coppia [valore, byteConsumati]
* oppure null se i dati sono incompleti.
*/
public function parse(string $buffer, int $offset = 0): ?array
{
// Se non ci sono byte da leggere il messaggio non e' ancora completo
if (!isset($buffer[$offset])) {
return null;
}
// Il primo byte determina il tipo del valore
return match ($buffer[$offset]) {
'+' => $this->parseLine($buffer, $offset),
'-' => $this->parseLine($buffer, $offset),
':' => $this->parseInteger($buffer, $offset),
'$' => $this->parseBulkString($buffer, $offset),
'*' => $this->parseArray($buffer, $offset),
default => $this->parseInline($buffer, $offset),
};
}
/**
* Estrae una riga terminata da CRLF, escludendo il prefisso di tipo.
*/
private function parseLine(string $buffer, int $offset): ?array
{
$crlf = strpos($buffer, self::CRLF, $offset);
if ($crlf === false) {
return null;
}
// Salta il carattere di tipo iniziale e si ferma prima del CRLF
$line = substr($buffer, $offset + 1, $crlf - $offset - 1);
return [$line, $crlf + 2];
}
private function parseInteger(string $buffer, int $offset): ?array
{
$result = $this->parseLine($buffer, $offset);
if ($result === null) {
return null;
}
[$line, $consumed] = $result;
return [(int) $line, $consumed];
}
private function parseBulkString(string $buffer, int $offset): ?array
{
// La prima riga contiene la lunghezza in byte della stringa
$header = $this->parseLine($buffer, $offset);
if ($header === null) {
return null;
}
[$lengthString, $consumed] = $header;
$length = (int) $lengthString;
// Lunghezza -1 indica una bulk string nulla
if ($length === -1) {
return [null, $consumed];
}
// Verifica che siano disponibili tutti i byte del dato piu' il CRLF finale
if (strlen($buffer) < $consumed + $length + 2) {
return null;
}
$data = substr($buffer, $consumed, $length);
return [$data, $consumed + $length + 2];
}
private function parseArray(string $buffer, int $offset): ?array
{
// La prima riga contiene il numero di elementi dell'array
$header = $this->parseLine($buffer, $offset);
if ($header === null) {
return null;
}
[$countString, $consumed] = $header;
$count = (int) $countString;
// Conteggio -1 indica un array nullo
if ($count === -1) {
return [null, $consumed];
}
$elements = [];
// Analizza ricorsivamente ogni elemento dell'array
for ($i = 0; $i < $count; $i++) {
$element = $this->parse($buffer, $consumed);
if ($element === null) {
// Un elemento e' incompleto: attende altri byte
return null;
}
[$value, $consumed] = $element;
$elements[] = $value;
}
return [$elements, $consumed];
}
/**
* Supporta il protocollo inline: una riga di testo con argomenti
* separati da spazi, utile per i test manuali via telnet.
*/
private function parseInline(string $buffer, int $offset): ?array
{
$crlf = strpos($buffer, self::CRLF, $offset);
if ($crlf === false) {
return null;
}
$line = substr($buffer, $offset, $crlf - $offset);
$parts = preg_split('/\s+/', trim($line)) ?: [];
// Rimuove eventuali token vuoti
$parts = array_values(array_filter($parts, static fn ($p) => $p !== ''));
return [$parts, $crlf + 2];
}
}
Si noti come parseArray() richiami ricorsivamente parse() per ogni elemento: poiché i comandi sono array di Bulk String, questa struttura ricorsiva gestisce in modo naturale qualsiasi annidamento. Se in un punto qualsiasi mancano dei byte, l'intera operazione restituisce null e il chiamante saprà che deve attendere ulteriori dati dalla rete prima di riprovare.
La serializzazione delle risposte
Una volta eseguito un comando dobbiamo restituire al client una risposta nel formato RESP. Il serializzatore espone un metodo per ciascun tipo di risposta che dovremo produrre.
<?php
declare(strict_types=1);
final class RespSerializer
{
private const CRLF = "\r\n";
public function simpleString(string $value): string
{
return '+' . $value . self::CRLF;
}
public function error(string $message): string
{
return '-' . $message . self::CRLF;
}
public function integer(int $value): string
{
return ':' . $value . self::CRLF;
}
public function bulkString(?string $value): string
{
// Una stringa nulla viene codificata con lunghezza -1
if ($value === null) {
return '$-1' . self::CRLF;
}
return '$' . strlen($value) . self::CRLF . $value . self::CRLF;
}
/**
* Assembla un array RESP a partire da elementi gia' serializzati.
*
* @param string[] $items
*/
public function array(array $items): string
{
$output = '*' . count($items) . self::CRLF;
foreach ($items as $item) {
$output .= $item;
}
return $output;
}
}
Il metodo array() riceve elementi che sono già stati serializzati singolarmente, tipicamente delle Bulk String. Sarà compito dei comandi che restituiscono collezioni (come LRANGE o HGETALL) costruire l'elenco di stringhe da passargli.
Il motore di memorizzazione
Il cuore del database è un semplice dizionario che associa chiavi a valori. Accanto a esso manteniamo un secondo dizionario che registra il timestamp di scadenza delle chiavi che hanno un tempo di vita limitato. La strategia di scadenza che adottiamo è quella lazy: una chiave scaduta non viene rimossa attivamente, ma solo nel momento in cui qualcuno tenta di accedervi. È la stessa logica che Redis usa per molte operazioni.
<?php
declare(strict_types=1);
final class Store
{
/** @var array<string, mixed> Il keyspace principale */
private array $values = [];
/** @var array<string, float> Scadenze come timestamp Unix con microsecondi */
private array $expirations = [];
public function get(string $key): mixed
{
$this->evictIfExpired($key);
return $this->values[$key] ?? null;
}
public function set(string $key, mixed $value): void
{
$this->values[$key] = $value;
// Una nuova assegnazione azzera l'eventuale scadenza precedente
unset($this->expirations[$key]);
}
public function has(string $key): bool
{
$this->evictIfExpired($key);
return array_key_exists($key, $this->values);
}
public function remove(string $key): bool
{
$existed = array_key_exists($key, $this->values);
unset($this->values[$key], $this->expirations[$key]);
return $existed;
}
/**
* @return string[] L'elenco delle chiavi ancora valide
*/
public function keys(): array
{
// Forza la valutazione della scadenza su tutte le chiavi
foreach (array_keys($this->values) as $key) {
$this->evictIfExpired($key);
}
return array_keys($this->values);
}
public function flush(): void
{
$this->values = [];
$this->expirations = [];
}
public function expireAt(string $key, float $timestamp): bool
{
if (!$this->has($key)) {
return false;
}
$this->expirations[$key] = $timestamp;
return true;
}
public function expirationOf(string $key): ?float
{
return $this->expirations[$key] ?? null;
}
/**
* Rimuove la chiave se la sua scadenza e' gia' trascorsa.
*/
private function evictIfExpired(string $key): void
{
if (!isset($this->expirations[$key])) {
return;
}
if (microtime(true) >= $this->expirations[$key]) {
unset($this->values[$key], $this->expirations[$key]);
}
}
}
I valori memorizzati possono essere stringhe (per il tipo string), array sequenziali (per le liste) o array associativi (per gli hash). La distinzione tra liste e hash avverrà nei singoli comandi: è una semplificazione che discuteremo nelle limitazioni finali.
Il dispatcher dei comandi
Il dispatcher è il livello che traduce un elenco di argomenti in un'operazione concreta sullo Store e nella relativa risposta serializzata. Il primo argomento è il nome del comando, gli altri sono i suoi parametri. Un'espressione match instrada ogni comando verso il metodo che lo gestisce.
<?php
declare(strict_types=1);
final class CommandDispatcher
{
public function __construct(
private readonly Store $store,
private readonly RespSerializer $serializer,
) {
}
/**
* @param string[] $arguments Nome del comando seguito dai suoi parametri
*/
public function dispatch(array $arguments): string
{
if ($arguments === []) {
return $this->serializer->error('ERR empty command');
}
// Il nome del comando e' case-insensitive
$name = strtoupper((string) array_shift($arguments));
return match ($name) {
'PING' => $this->ping($arguments),
'ECHO' => $this->echo($arguments),
'SET' => $this->set($arguments),
'GET' => $this->get($arguments),
'DEL' => $this->del($arguments),
'EXISTS' => $this->exists($arguments),
'EXPIRE' => $this->expire($arguments),
'TTL' => $this->ttl($arguments),
'INCR' => $this->incrementBy($arguments, 1),
'DECR' => $this->incrementBy($arguments, -1),
'LPUSH' => $this->push($arguments, true),
'RPUSH' => $this->push($arguments, false),
'LRANGE' => $this->lrange($arguments),
'LLEN' => $this->llen($arguments),
'HSET' => $this->hset($arguments),
'HGET' => $this->hget($arguments),
'HGETALL' => $this->hgetall($arguments),
'KEYS' => $this->keys($arguments),
'FLUSHDB' => $this->flushdb(),
'COMMAND' => $this->serializer->array([]),
default => $this->serializer->error(
sprintf("ERR unknown command '%s'", $name)
),
};
}
private function ping(array $args): string
{
if ($args === []) {
return $this->serializer->simpleString('PONG');
}
return $this->serializer->bulkString((string) $args[0]);
}
private function echo(array $args): string
{
if (count($args) !== 1) {
return $this->wrongArgs('echo');
}
return $this->serializer->bulkString((string) $args[0]);
}
private function set(array $args): string
{
if (count($args) < 2) {
return $this->wrongArgs('set');
}
$key = (string) $args[0];
$value = (string) $args[1];
$this->store->set($key, $value);
// Gestione delle opzioni EX (secondi) e PX (millisecondi)
$options = array_slice($args, 2);
$optionCount = count($options);
for ($i = 0; $i < $optionCount; $i++) {
$option = strtoupper((string) $options[$i]);
if ($option === 'EX' && isset($options[$i + 1])) {
$this->store->expireAt($key, microtime(true) + (int) $options[$i + 1]);
$i++;
} elseif ($option === 'PX' && isset($options[$i + 1])) {
$this->store->expireAt($key, microtime(true) + (int) $options[$i + 1] / 1000);
$i++;
}
}
return $this->serializer->simpleString('OK');
}
private function get(array $args): string
{
if (count($args) !== 1) {
return $this->wrongArgs('get');
}
$value = $this->store->get((string) $args[0]);
if ($value === null) {
return $this->serializer->bulkString(null);
}
// GET opera solo su valori di tipo stringa
if (!is_string($value)) {
return $this->wrongType();
}
return $this->serializer->bulkString($value);
}
private function del(array $args): string
{
if ($args === []) {
return $this->wrongArgs('del');
}
$removed = 0;
foreach ($args as $key) {
if ($this->store->remove((string) $key)) {
$removed++;
}
}
return $this->serializer->integer($removed);
}
private function exists(array $args): string
{
if ($args === []) {
return $this->wrongArgs('exists');
}
$found = 0;
foreach ($args as $key) {
if ($this->store->has((string) $key)) {
$found++;
}
}
return $this->serializer->integer($found);
}
private function expire(array $args): string
{
if (count($args) !== 2) {
return $this->wrongArgs('expire');
}
$applied = $this->store->expireAt(
(string) $args[0],
microtime(true) + (int) $args[1]
);
return $this->serializer->integer($applied ? 1 : 0);
}
private function ttl(array $args): string
{
if (count($args) !== 1) {
return $this->wrongArgs('ttl');
}
$key = (string) $args[0];
if (!$this->store->has($key)) {
// -2 indica una chiave inesistente
return $this->serializer->integer(-2);
}
$expiration = $this->store->expirationOf($key);
if ($expiration === null) {
// -1 indica una chiave senza scadenza
return $this->serializer->integer(-1);
}
return $this->serializer->integer((int) ceil($expiration - microtime(true)));
}
private function incrementBy(array $args, int $delta): string
{
if (count($args) !== 1) {
return $this->wrongArgs($delta > 0 ? 'incr' : 'decr');
}
$key = (string) $args[0];
$current = $this->store->get($key);
if ($current !== null && !is_string($current)) {
return $this->wrongType();
}
if ($current !== null && !$this->isInteger($current)) {
return $this->serializer->error('ERR value is not an integer or out of range');
}
// Una chiave assente viene trattata come zero
$next = (int) ($current ?? 0) + $delta;
$this->store->set($key, (string) $next);
return $this->serializer->integer($next);
}
private function push(array $args, bool $left): string
{
if (count($args) < 2) {
return $this->wrongArgs($left ? 'lpush' : 'rpush');
}
$key = (string) array_shift($args);
$list = $this->store->get($key);
if ($list === null) {
$list = [];
} elseif (!is_array($list)) {
return $this->wrongType();
}
foreach ($args as $value) {
if ($left) {
// LPUSH inserisce in testa alla lista
array_unshift($list, (string) $value);
} else {
// RPUSH accoda alla fine della lista
$list[] = (string) $value;
}
}
$this->store->set($key, $list);
return $this->serializer->integer(count($list));
}
private function lrange(array $args): string
{
if (count($args) !== 3) {
return $this->wrongArgs('lrange');
}
$list = $this->store->get((string) $args[0]);
if ($list === null) {
return $this->serializer->array([]);
}
if (!is_array($list)) {
return $this->wrongType();
}
$length = count($list);
$start = $this->normalizeIndex((int) $args[1], $length);
$stop = $this->normalizeIndex((int) $args[2], $length);
$items = [];
for ($i = $start; $i <= $stop && $i < $length; $i++) {
$items[] = $this->serializer->bulkString((string) $list[$i]);
}
return $this->serializer->array($items);
}
private function llen(array $args): string
{
if (count($args) !== 1) {
return $this->wrongArgs('llen');
}
$list = $this->store->get((string) $args[0]);
if ($list === null) {
return $this->serializer->integer(0);
}
if (!is_array($list)) {
return $this->wrongType();
}
return $this->serializer->integer(count($list));
}
private function hset(array $args): string
{
// Servono la chiave piu' un numero pari di coppie campo/valore
if (count($args) < 3 || count($args) % 2 === 0) {
return $this->wrongArgs('hset');
}
$key = (string) array_shift($args);
$hash = $this->store->get($key);
if ($hash === null) {
$hash = [];
} elseif (!is_array($hash)) {
return $this->wrongType();
}
$added = 0;
$pairCount = count($args);
// Scorre le coppie campo/valore a passo di due
for ($i = 0; $i < $pairCount; $i += 2) {
$field = (string) $args[$i];
$value = (string) $args[$i + 1];
if (!array_key_exists($field, $hash)) {
$added++;
}
$hash[$field] = $value;
}
$this->store->set($key, $hash);
return $this->serializer->integer($added);
}
private function hget(array $args): string
{
if (count($args) !== 2) {
return $this->wrongArgs('hget');
}
$hash = $this->store->get((string) $args[0]);
if ($hash === null) {
return $this->serializer->bulkString(null);
}
if (!is_array($hash)) {
return $this->wrongType();
}
$value = $hash[(string) $args[1]] ?? null;
return $this->serializer->bulkString($value === null ? null : (string) $value);
}
private function hgetall(array $args): string
{
if (count($args) !== 1) {
return $this->wrongArgs('hgetall');
}
$hash = $this->store->get((string) $args[0]);
if ($hash === null) {
return $this->serializer->array([]);
}
if (!is_array($hash)) {
return $this->wrongType();
}
$items = [];
// Restituisce una sequenza alternata di campo e valore
foreach ($hash as $field => $value) {
$items[] = $this->serializer->bulkString((string) $field);
$items[] = $this->serializer->bulkString((string) $value);
}
return $this->serializer->array($items);
}
private function keys(array $args): string
{
if (count($args) !== 1) {
return $this->wrongArgs('keys');
}
$pattern = (string) $args[0];
// Converte il pattern in stile glob in una espressione regolare
$regex = '/^' . str_replace(
['\\*', '\\?'],
['.*', '.'],
preg_quote($pattern, '/')
) . '$/';
$items = [];
foreach ($this->store->keys() as $key) {
if (preg_match($regex, $key) === 1) {
$items[] = $this->serializer->bulkString($key);
}
}
return $this->serializer->array($items);
}
private function flushdb(): string
{
$this->store->flush();
return $this->serializer->simpleString('OK');
}
private function normalizeIndex(int $index, int $length): int
{
// Gli indici negativi partono dalla fine della lista
if ($index < 0) {
$index = $length + $index;
}
return max(0, $index);
}
private function isInteger(string $value): bool
{
return (string) (int) $value === $value;
}
private function wrongArgs(string $command): string
{
return $this->serializer->error(
sprintf("ERR wrong number of arguments for '%s' command", $command)
);
}
private function wrongType(): string
{
return $this->serializer->error(
'WRONGTYPE Operation against a key holding the wrong kind of value'
);
}
}
Ogni handler segue lo stesso schema: valida il numero di argomenti, controlla il tipo del valore esistente quando necessario, esegue l'operazione e produce la risposta serializzata. Le costanti come -2 e -1 per TTL non sono arbitrarie ma riproducono le convenzioni di Redis, così che i client si comportino esattamente come si aspettano. Il comando COMMAND riceve una risposta vuota perché redis-cli lo invia automaticamente all'avvio di una sessione interattiva: rispondergli con un array vuoto evita errori.
Il server TCP
Manca l'ultimo tassello: il server che accetta connessioni e mette in comunicazione i client con il dispatcher. Useremo un singolo processo con un event loop non bloccante basato su stream_select(). Questa funzione attende che uno o più socket abbiano dati da leggere, permettendoci di gestire molti client contemporaneamente senza ricorrere a thread o processi separati. È lo stesso modello a multiplexing degli I/O che usa Redis stesso.
Per ogni client manteniamo un buffer di lettura, perché come abbiamo visto i comandi possono arrivare frammentati. Ogni volta che riceviamo nuovi byte li accodiamo al buffer e poi cerchiamo di estrarre tutti i comandi completi disponibili.
<?php
declare(strict_types=1);
final class Server
{
private const READ_CHUNK = 16384;
/** @var resource Il socket di ascolto */
private $listener;
/** @var array<int, resource> Connessioni client attive */
private array $clients = [];
/** @var array<int, string> Buffer di lettura per ogni client */
private array $buffers = [];
public function __construct(
private readonly string $host,
private readonly int $port,
private readonly RespParser $parser,
private readonly CommandDispatcher $dispatcher,
) {
}
public function run(): void
{
$address = sprintf('tcp://%s:%d', $this->host, $this->port);
$listener = stream_socket_server($address, $errorCode, $errorMessage);
if ($listener === false) {
throw new RuntimeException(
sprintf('Impossibile avviare il server: %s (%d)', $errorMessage, $errorCode)
);
}
$this->listener = $listener;
// Il socket di ascolto non deve bloccare l'event loop
stream_set_blocking($this->listener, false);
printf("Server in ascolto su %s\n", $address);
// Ciclo principale del server
while (true) {
$this->tick();
}
}
private function tick(): void
{
// Monitora in lettura tutti i client piu' il socket di ascolto
$readable = $this->clients;
$readable[] = $this->listener;
$write = null;
$except = null;
// Si blocca finche' almeno un socket non ha attivita'
if (@stream_select($readable, $write, $except, null) === false) {
return;
}
foreach ($readable as $stream) {
if ($stream === $this->listener) {
$this->acceptClient();
} else {
$this->handleClient($stream);
}
}
}
private function acceptClient(): void
{
$client = @stream_socket_accept($this->listener, 0);
if ($client === false) {
return;
}
// Anche i socket dei client lavorano in modo non bloccante
stream_set_blocking($client, false);
$id = (int) $client;
$this->clients[$id] = $client;
$this->buffers[$id] = '';
}
private function handleClient($client): void
{
$id = (int) $client;
$chunk = fread($client, self::READ_CHUNK);
// Stringa vuota o false segnalano la chiusura della connessione
if ($chunk === false || $chunk === '') {
$this->disconnect($client);
return;
}
$this->buffers[$id] .= $chunk;
$this->drainBuffer($client);
}
private function drainBuffer($client): void
{
$id = (int) $client;
// Estrae ed esegue tutti i comandi completi presenti nel buffer
while ($this->buffers[$id] !== '') {
$result = $this->parser->parse($this->buffers[$id]);
if ($result === null) {
// Comando incompleto: si attende altro input dalla rete
break;
}
[$value, $consumed] = $result;
$this->buffers[$id] = substr($this->buffers[$id], $consumed);
$arguments = $this->normalizeArguments($value);
$response = $this->dispatcher->dispatch($arguments);
fwrite($client, $response);
}
}
/**
* Normalizza il valore decodificato in un elenco di argomenti stringa.
*/
private function normalizeArguments(mixed $value): array
{
// I client inviano i comandi come array di bulk string,
// ma supportiamo anche il protocollo inline su una singola riga
if (is_array($value)) {
return array_map(static fn ($item) => (string) $item, $value);
}
return [(string) $value];
}
private function disconnect($client): void
{
$id = (int) $client;
fclose($client);
unset($this->clients[$id], $this->buffers[$id]);
}
}
Il metodo drainBuffer() è la chiave dell'intera gestione: dopo ogni lettura cerca di estrarre quanti più comandi completi possibile, rimuovendo dal buffer i byte già consumati. Se nel buffer resta un comando parziale, il ciclo si interrompe e i byte restano in attesa della lettura successiva. In questo modo gestiamo correttamente sia i comandi spezzati sia i comandi in pipeline, cioè inviati in blocco senza attendere le risposte intermedie.
Il punto di ingresso
Non resta che assemblare i componenti e avviare il server. Salviamo questo codice in un file server.php insieme alle classi viste sopra (oppure includendole, se le si tiene in file separati).
<?php
declare(strict_types=1);
// Composizione delle dipendenze
$store = new Store();
$serializer = new RespSerializer();
$parser = new RespParser();
$dispatcher = new CommandDispatcher($store, $serializer);
// Avvio del server sulla porta standard di Redis
$server = new Server('127.0.0.1', 6379, $parser, $dispatcher);
$server->run();
Provare il server
Avviamo il server da riga di comando:
php server.php
In un altro terminale possiamo collegarci con il client ufficiale di Redis, che parla esattamente il protocollo RESP che abbiamo implementato:
redis-cli -p 6379
A questo punto possiamo eseguire i comandi come faremmo con un Redis reale:
127.0.0.1:6379> PING
PONG
127.0.0.1:6379> SET name Mario
OK
127.0.0.1:6379> GET name
"Mario"
127.0.0.1:6379> SET counter 10
OK
127.0.0.1:6379> INCR counter
(integer) 11
127.0.0.1:6379> RPUSH colors red green blue
(integer) 3
127.0.0.1:6379> LRANGE colors 0 -1
1) "red"
2) "green"
3) "blue"
127.0.0.1:6379> HSET user name Mario role admin
(integer) 2
127.0.0.1:6379> HGETALL user
1) "name"
2) "Mario"
3) "role"
4) "admin"
127.0.0.1:6379> SET session abc EX 60
OK
127.0.0.1:6379> TTL session
(integer) 60
127.0.0.1:6379> KEYS *
1) "name"
2) "counter"
3) "colors"
4) "user"
5) "session"
Se non si dispone di redis-cli, qualunque libreria client per Redis funzionerà allo stesso modo, perché tutte parlano RESP. Anche un semplice telnet 127.0.0.1 6379 permette di provare i comandi grazie al supporto per il protocollo inline.
Limitazioni e possibili estensioni
Il nostro clone è didattico e fa alcune scelte di compromesso che vale la pena rendere esplicite. La distinzione tra liste e hash si basa sulla forma dell'array PHP: un hash i cui campi fossero numeri interi consecutivi potrebbe essere confuso con una lista. Una soluzione più robusta consiste nell'incapsulare ogni valore in un piccolo oggetto tipizzato che dichiari esplicitamente il proprio tipo. Inoltre il comando INCR, basandosi su set(), azzera l'eventuale scadenza della chiave, mentre il Redis reale la preserva: per replicarne il comportamento basterebbe leggere e ripristinare la scadenza dopo l'aggiornamento.
Sul fronte delle funzionalità, mancano molti pezzi del Redis vero: la persistenza su disco (snapshot RDB o append-only file AOF), il meccanismo di pubblicazione e sottoscrizione, le transazioni con MULTI ed EXEC, gli insiemi e gli insiemi ordinati, la scadenza attiva tramite un campionamento periodico delle chiavi, l'autenticazione e la replica. Ognuno di questi temi è un ottimo esercizio successivo che si innesta naturalmente sull'architettura che abbiamo costruito.
Dal punto di vista delle prestazioni, infine, il nostro server resta su un singolo thread con multiplexing degli I/O, proprio come Redis: è un modello sorprendentemente efficiente perché l'operazione su un dizionario in memoria è velocissima e il collo di bottiglia è quasi sempre la rete. Per spingersi oltre si potrebbe ottimizzare la gestione dei buffer di scrittura, evitando che una fwrite() bloccante rallenti l'intero loop quando un client è lento a leggere.
Conclusione
Abbiamo costruito, in poche centinaia di righe di PHP, un server compatibile con Redis capace di gestire stringhe, contatori, liste, hash e scadenze delle chiavi, dialogando senza problemi con il client ufficiale. Il valore di questo esercizio non sta nel risultato in sé, ma nella comprensione che se ne ricava: dietro a un'infrastruttura che diamo per scontata ogni giorno ci sono idee chiare e implementabili, dal parsing di un protocollo testuale all'event loop non bloccante. Partendo da questa base, aggiungere persistenza, nuovi tipi di dato o il pub/sub diventa un percorso di apprendimento naturale e gratificante.