febbraio 8, 2021

Wrapper NodeJS attorno a Redis

Redis è un ottimo database non relazionale utilizzato tipicamente come cache o storage di dati debolmente strutturati per i quali sono richieste altissime prestazioni e operazioni atomiche. Per l'hobbista è anche un comodo sostituto di un db tradizionale quando si vogliono salvare poche informazioni: si installa in un minuto (anche meno usando Docker) e non richiede l'impostazione di una struttura in SQL. Per un recente progetto personale ho avuto bisogno proprio di qualcosa del genere: salvare pochi array di stringhe e pochi valori numerici senza essere costretto ad installare un MariaDB apposta.

In NodeJS la libreria ufficialmente supportata si chiama, guarda caso, redis. Ha un solo problema: non è strutturata in maniera modernissima. In particolar modo non utilizza il paradigma delle Promise, ma ad ogni funzione richiede di fornire un callback; se le chiamate sono un pò o sono consecutive la cosa può generare disordine.

client.set(["key1", "value1"], function(err, res) {
  if (err){
    ...
  }
  else {
    client.set(["key2", "value2"], functions(err, res) {
      if (err) {
        ...
      }
      else {
        ...eccetera
      }
    })
  }
});

Per comodità e cultura personale mi sono costruito un piccolo wrapper attorno a questa libreria, e di questo vorrei parlare oggi. L'idea è quella di wrappare tutto dentro a delle Promise, per semplificare la scrittura di operazioni sequenziali in cui è richiesta l'attesa del termine di una prima di iniziare la successiva. L'esempio sopra potrebbe essere scritto come qualcosa del genere:

async function main(){
  try{
    await client.set(["key1", "value1"]);
    await client.set(["key2", "value2"]);
    //eccetera
  }
  catch e{
    // gestione errori
  }
}

main()

Per prima cosa ho creato una classe, destinata a contenere tutto il resto del codice:

import Redis from 'redis';
export default class redisConnector{

Poi il costruttore:


/**
 * Constructor.
 * @param {string} redisHost REDIS host (default: localhost)
 * @param {number} redisPort REDIS port number (default: 6379)
 * @param {function} onTotalFailure Callback in case of failure to connect/reconnect (default: process.exit)
 */
constructor(redisHost, redisPort, onTotalFailure = () => process.exit()){

  this.redisHost = redisHost || 'localhost';
  this.redisPort = redisPort || 6379;
  this.redisClient = null;
  this.isConnected = false;
  this.connectionRefused = false;
  this.onTotalFailure = onTotalFailure;

  // Creates redis commands as class methods.
  ['get', 'set', 'del', 'sadd', 'srem', 'hset', 'hget', 'smembers', 'sismember'].forEach(command => {
      this[command] = (...params) => this._redisCommand(command, ...params)
  })        

}

Il costruttore si occupa di:

Dopo il costruttore impostiamo un metodo per uso interno che viene usato dal client Redis per gestire le riconnessioni e valutare se riprovare a connettersi o rinunciare. Nella mia classe ho impostato questa politica: prova a connettersi ogni 10 secondi, e dopo 60 secondi o 10 tentativi di connessione falliti rinuncia e chiama il metodo onTotalFailure di cui abbiamo discusso in precedenza (ogni tentativo può richiedere del tempo, da cui l'esigenza di misurare sia il tempo totale che il numero di tentativi per evitare il rischio di rimanere bloccati a lungo sulla riconnessione).

/**
 * REDIS connect/reconnect strategy.
 * @param {error, total_retry_time, attempt} param0 
 */
_redis_retry_strategy = ({error, total_retry_time, attempt}) => {

  // Total time: 60 sec
  if (total_retry_time > 1000 * 60) {
    console.log(`Unable to reach Redis instance on ${this.redisHost}:${this.redisPort}, retry time exhausted, exiting.`);
    this.onTotalFailure();
    return null;
  }

  // Max connection attempts: 10
  if (attempt > 10) {
    console.log(`Unable to reach Redis instance on ${this.redisHost}:${this.redisPort}, retry attempts exhausted, exiting.`);
    this.onTotalFailure();
    return null;
  }

  console.log(`Unable to reach Redis instance on ${this.redisHost}:${this.redisPort}, will retry in ${SECONDS_REDIS_CONN_RETRY} seconds...`);
    
  // Next attempt in 10 seconds.
  return 10000; 

}

A questo punto il metodo di connessione. Il metodo di connessione richiama il client della libreria passando i parametri necessari e fissa dei callback sugli eventi di connessione/fallimento. Non è un metodo bloccante: è eseguito immediatamente, e una volta terminato non c'è garanzia che la connessione sia instaurata; questo è qualcosa che dovremo gestire a valle. L'unica cosa che gestiamo è una variabile isConnected, che verrà messa a true se e solo se la connessione andrà a buon fine. Viceversa, resterà a false e al termine dei tentativi concessi (dal metodo visto prima) verrà lanciato il onTotalFailure.

/**
 * Starts connection attempt.
 */
connect = () => {

  // Init connection
  console.log(`Attempting Redis connection on ${this.redisHost}:${this.redisPort}...`);
  this.redisClient = Redis.createClient({
    host: this.redisHost,
    port: this.redisPort,
    retry_strategy: this._redis_retry_strategy
  });

  // Callback after successful redis connection
  this.redisClient.on("connect", () => {
    console.log("Redis connection established.");
    this.isConnected = true;            
  });          

  // Callback after disconnection
  this.redisClient.on("end", () => {
    console.log("Redis connection lost.");
    this.isConnected = false;            
  });          

}

Le problematiche di sincronizzazione relative alla connessione sono dipanate dal cuore del sistema, il metodo getClient(). Questo metodo viene chiamato dall'esterno ogni qual volta un programma ha bisogno di usare Redis e ritorna una Promise. Se la connessione è già disponibile (verificando il flag isConnected), la Promise si risolve subito nella connessione stessa, e tutti sono felici&contenti. Se la connessione non è disponibile, invece, continua a non risolvere la Promise, attende un pochino e poi verifica ancora la connessione, e così via a meno che qualcuno a un certo punto non imposti il flag "connectionRefused" che causa la risoluzione della Promise con un errore.

/**
 * Retrieve client. Returns a Promise which resolves with the 
 * redis client as soon as the connection is available.
 * @returns {Promise<Redis.RedisClient>}
 */
getClient = () => {
  return new Promise((accept, reject) => {
        
    if (this.connectionRefused){
      reject("Unable to connect to redis server")
    }
        
    if (this.isConnected){
      accept(this.redisClient)
    }

    function check() {
      if (this.connectionRefused){
        reject("Unable to connect to redis server")
      }                    
      else if (this.isConnected){
        accept(this.redisClient)
      }         
      else {
        setTimeout(check.bind(this), 500)       
      }
    }

    // Check again in half a second
    setTimeout(check.bind(this), 500)        
  
  })
}

Per concludere, oltre alla gestione della connessione ho anche voluto realizzare un wrapper attorno ai metodi della libreria Redis per renderli delle Promise. Fortunatamente i comandi della libreria hanno tutti la medesima struttura: accettano una serie di parametri (dipendenti dal comando stesso) l'ultimo dei quali è sempre un callback con due proprietà, err e value. Err contiene l'eventuale errore riscontrato dal db durante la chiamata, value contiene l'output della funzione. Avendo una struttura comune, è stato possibile costruire una funzione standard in grado di wrappare ogni comando e ritornare una Promise con il risultato:

/**
 * Prototype for any redis command.
 * @param {string} commandName 
 * @param  {...string|number} params 
 */
_redisCommand = (commandName, ...params) => {
  return new Promise(async (resolve, reject) => {
    const client = await this.getClient();
    client[commandName](...params, (err, value) => {
      if (err != null){
        reject(err);
      }
      resolve(value);
    })		
  })
}

A questo punto la classe è conclusa. La chiudiamo con la parentesi graffa chiusa e la salviamo in un bel file, magari qualcosa tipo "redis-connector.js". A questo punto possiamo realizzare un piccolo esempio di programma che utilizza il connector appena costruito.

import RedisConnector from './redis-connector';

// Connect to REDIS server
const redisclient = new RedisConnector('localhost', 6379);
redisclient.connect();

// Waits for the connection to be up, then runs main method.
redisclient
  .getClient()
  .then(main)
  .catch((err) => {
    log.error("Unexpected error in main(), quitting.", err.message)
    process.exit()
  });

/**
 * Application entry point (called after Redis connection established).
 */
async function main(){
  await redisClient.set("chiave1", "valore1");
  await redisClient.set("chiave2", "valore2");
}

Voilà!

Redis è un sistema estremamente interessante per salvare dati debolmente strutturati e garantirne la sopravvivenza tra una sessione e l'altra. Inoltre ha una serie di funzionalità molto, molto interessanti per gestione di array, liste anche ordinate e timeseries. Per ulteriori dettagli vi rimando al sito ufficiale, dove potrete trovare un elenco completo dei comandi disponibili e un simpatico simulatore/tutorial che vi permette di fare prove direttamente sul sito.