Nella lezione precedente abbiamo appreso le basi del pattern di comunicazione basato su longpolling. Per offrire un'applicazione "live" agli utenti però abbiamo introdotto una serie di complessità notevoli che rendono diffocoltoso lo sviluppo.
Tutte le applicazioni long-polling condividono una serie di aspetti, per questo in questa lezione esamineremo una libreria che ho creato per semplificare l'implementazione di questo pattern. La libreria si chiama Polly ed è disponibile sia su su NPM, sia su github.
L'applicazione che analizzeremo sfrutta Polly ed è una semplice chat (pollychat). Attualmente l'applicazione pubblicata su Nodester (piattaforma che approfondiremo nei prossimi articoli).
I concetti chiave di Polly
Prima di iniziare con l'analisi del codice dobbiamo introdurre alcuni concetti teorici che faciliteranno la comprensione della libreria. Questi concetti sono stati indirettamente introdotti nella lezione precedente ma per realizzare una libreria, e quindi un'astrazione, è necessario specificarli meglio.
Un'applicazione basata su long-polling implementa due concetti chiave:
- gli eventi: ovvero quello che succede e che deve essere notificato agli utenti. Ciascun evento viene identificato da un id per garantire l'integrità del flusso comunicativo e può essere specificato con alcuni parametri aggiuntivi. Nella nostra applicazione gli eventi possibili sono
message
,user_join
euser_part
. - i contesti: ovvero l'elenco delle sessioni attive in ciascun momento. Ciascuna sessione è
identificata da un id segreto (che permette di identificare l'utente), da oggetti request e
response che permettono l'asincronia nelle risposte e una serie di dati aggiuntivi sull'utente
(per esempio lo username).
Entrambi i tipi di oggetto verranno inseriti in un hash e identificati dal proprio id univoco. L'hash degli eventi sarà incrementale e non verrà mai modificato mentre quello dei contesti sarà soggetto a continue modifiche in base al flusso di utenti nell'applicazione.
La libreria sarà composta da due componenti:
- componente server-side che faciliterà lo sviluppo del server esponendo una serie di API
di facile comprensione e che ridurranno il codice del server a poche linee di codice. - componente client-side che, basandosi su jQuery, faciliterà l'invocazione remota delle
chiamate esposte dal server.
Essa dipenderà da due moduli aggiuntivi già visti in precedenza: cookie per la gestione appunto dei "biscottini" e ajn per la generazione di id univoci.
Polly, componente server-side
Partiamo analizzando il modulo server-side da importare nelle nostre applicazioni.
var Polly = function() {
this.contexts = { };
this.events = { };
this.eventCounter = 0;
this.backEvents = 5;
this.cookieName = "POLLY_ID";
}
Il costruttore inizializza una serie di variabili tra le quali i due hash contexts e events, un contatore di eventi, la variabile backEvents
(che analizzeremo dopo) e il nome del cookie dove verrà stoccato l'id della sessione.
Dopo il costruttore ecco una serie di metodi pubblici di utilità:
Polly.prototype.getSessionCount = function() {
return Object.keys(this.contexts).length;
}
Polly.prototype.getSession = function(id) {
return this.contexts[id];
}
Polly.prototype.getUserData = function(id) {
return this.contexts[id].data;
}
Polly.prototype.getSessionId = function(req) {
return cookie.getCookie(req, this.cookieName);
}
Polly.prototype.getAllSessionsData = function() {
var datas = [];
for(var i in this.contexts) {
datas.push(this.getSession(i).data);
}
return datas;
}
Metodo | Descrizione |
---|---|
getSessionCount |
ritorna il numero di sessioni attive in un determinato momento |
getSession |
ritorna una sessione a partire dall'id (letto da cookie) |
getUserData |
ritorna una mappa di proprietà legate all'utente di una particolare sessione |
getSessionId |
legge il valore del cookie a partire dalla request |
getAllSessionData |
aggrega in un vettore tutte le proprietà di tutti gli utenti (utile per mostrare lo stato dell'applicazione in un determinato istante) |
Ecco ora alcune funzionalità legate agli eventi:
Polly.prototype._getLastEventsById = function(eventId) {
var eventsToSend = [];
for(var i in this.events) {
if(this.events[i].id > eventId) eventsToSend.push(this.events[i]);
}
eventsToSend = eventsToSend.splice(-this.backEvents,this.backEvents);
return eventsToSend;
}
Polly.prototype._createEvent = function(eventType, eventData, user) {
var event = {
id: this.eventCounter++,
type: eventType,
data: eventData,
user: user
}
this.events[event.id] = event;
return event;
}
Il metodo privato _getLastEventsById
permette di sapere quali nuovi eventi sono accaduti a partire da un determinato id. Questo permette di risolvere due problemi:
- Utente che per problemi di rete non riceve un particolare evento: alla chiamata successiva riceverà anche l'evento precedente non ricevuto;
- Utente appena entrato nell'applicazione che riceverà gli ultimi eventi accaduti (la proprietà vista in precedenza backEvents serve proprio a indicare quanti eventi dovrà ricevere l'utente appena entrato nell'applicazione).
Il metodo privato _createEvent
invece permette di creare un oggetto utente a partire da un
eventType
, da una mappa di dati aggiuntivi e dall'utente che lo ha scatenato.
Spostiamoci ora sui metodi principali di Polly che permettono di mettere in piedi un flusso di comunicazione long-polling.
Polly.prototype.registerSession = function(req, res, data) {
var id = hat();
var uid = hat();
data.id = uid;
this.contexts[id] = {
id: id,
data: data,
d: new Date()
}
cookie.setCookie(res, this.cookieName, id, false, false, false, true);
this.pushEvent(id, "user_join", {});
console.log("Session " + id + " registered!");
return id;
}
RegisterSession deve essere invocato appena un utente entra nell'applicazione e permette di registrare una nuova sessione tra i contesti disponibili. Gli aspetti interessanti di questo metodo sono molteplici.
Innanzitutto vediamo la generazione di due id (id e uid). Il primo identifica l'id di sessione del client (infatti viene impostato nel cookie) e deve essere segreto. Recuperare questo id da qualche altro utente significa potersi autenticare come se si fosse lui, e questo non è cosa buona e giusta. Il secondo id invece rappresenta l'id pubblico di un utente, può essere comunicato a terzi e permette di identificare univocamente un determinato utente (per esempio nel caso di messaggi privati).
L'altro aspetto curioso è la chiamata al metodo pushEvent
. Questo metodo, che vedremo successivamente, permette appunto di appendere alla coda di eventi un nuovo evento in questo caso di tipo user_join
.
Polly.prototype.unregisterSession = function(id) {
if(this.contexts[id]) {
this.contexts[id].res.end(JSON.stringify({ status: false }));
this.pushEvent(id, "user_part", {});
delete this.contexts[id];
console.log("Session " + id + " destroyed!");
}
}
L'opposto di registerSession
non poteva che essere unregisterSession. Questo secondo metodo permette di eliminare una particolare sessione dai contesti attivi. Anche in questo caso viene creato un nuovo evento però di tipo user_part
.
Polly.prototype.holdSession = function(req, res) {
var id = this.getSessionId(req);
if(this.contexts[id]) {
var lastEventId = req.params.lastEventId;
var eventsToSend = this._getLastEventsById(lastEventId);
if(eventsToSend.length > 0) {
res.end(JSON.stringify({ status: true, events: eventsToSend }));
console.log(eventsToSend.length + " events sent to session " + id);
return true;
}
if(id && this.contexts[id]) {
this.contexts[id].req = req;
this.contexts[id].res = res;
this.contexts[id].d = new Date();
console.log("Session " + id + " holded!");
return true;
}
} return false;
}
Il metodo holdSession rappresenta il vero cuore dell'applicazione. Viene invocato una volta registrata una sessione e implementa il vero meccanismo delle risposte asincrone. Innanzitutto recupera l'ultimo evento inviato al client e controlla se esistono eventi già creati ma non ancora inviati al client e in caso positivo li invia chiudendo di fatto la chiamata. Nel caso però più comune aggiorna il contesto attuale inserendo gli oggetti request e response e rinfrescando la data e mette la chiamata in uno stato di attesa.
Polly.prototype.pushEvent = function(id, eventType, eventData) {
var user = this.getUserData(id);
var event = this._createEvent(eventType, eventData, user);
for(var i in this.contexts) {
if(this.getSession(i).res)
this.getSession(i).res.end(JSON.stringify({ status: true, events: [event] }));
}
}
Il metodo pushEvent
rappresenta il corrispettivo di holdSession
il quale mette in attesa la response al client. Quest'ultimo invece si occupa di notificare agli utenti i nuovi eventi ricevuti ciclando tutte le sessioni attive.
Gli ultimi due metodi sono "di pulizia":
Polly.prototype._refreshContext = function() {
var now = new Date();
for(var i in this.contexts) {
var c = this.contexts[i];
if(now - c.d > this.refreshTimeout) {
this.contexts[i].res.end(JSON.stringify({ status: true }));
console.log("Session "+c.id+" refreshed!");
}
}
}
Polly.prototype._purgeContext = function() {
var now = new Date();
for(var i in this.contexts) {
var c = this.contexts[i];
if(now - c.d > this.purgeTimeout) {
this.unregisterSession(i);
console.log("Session "+c.id+" purged!");
}
}
}
_refreshContext permette di aggiornare, ogni refreshTimeout secondi, quei contesti appesi a fronte di mancanza di eventi per evitare che il client vada in timeout mentre _purgeContext permette di eliminare quelle sessioni che, dopo un determinato numero di secondi (purgeTimeout).
Queste funzioni sono fondamentali per avere un'applicazione affidabile che, anche a fronte di problemi di rete o di utenti "maleducati", risulta coerente e stabile. Entrambe sono da eseguire in maniera temporizzata quindi è necessario modificare il costruttore in questo modo:
var Polly = function() {
// [...]
this.refreshTimeout = 3333;
this.purgeTimeout = 10000;
var polly = this;
this.refresher = setInterval(function() {
polly.refreshContext();
}, this._refreshTimeout);
this.purger = setInterval(function() {
polly._purgeContext();
}, this.refreshTimeout);
}
Ogni 3,33 secondi:
- le sessioni più vecchie di 3,33 secondi verranno rinfrescate senza nessun nuovo evento
- le sessioni più vecche di 10 secondi verranno considerate scadute
Questi numeri sono abbastanza esosi in termini di risorse. A fronte di un aumento delle richieste sarà necessario ritararli per non appesantire troppo sia il server che i vari client.
Il file server.js
Una volta definita la libreria possiamo spostare la nostra attenzione sul server vero e proprio, concentrandoci quindi sulla tipologia di applicazione che vogliamo realizzare: una semplice chat.
Il componente server si appoggierà come sempre al modulo httpdispatcher. Gli entry point implementati saranno 4: /register
, /hold
, /push
e /part
:
dispatcher.onGet('/register', function(req, res) {
var data = {
username: req.params['username']
}
var users = polly.getAllSessionsData();
var id = polly.registerSession(req, res, data);
res.end(JSON.stringify({
status: true,
users: users
}));
});
dispatcher.onGet('/hold', function(req, res) {
polly.holdSession(req, res);
});
dispatcher.onGet('/push', function(req, res) {
polly.pushEvent(polly.getSessionId(req), "message", {
message: req.params.message
});
res.end(JSON.stringify({
status: true
}));
});
dispatcher.onGet('/part', function(req, res) {
polly.unregisterSession(polly.getSessionId(req));
res.end(JSON.stringify({
status: true
}));
});
Come è facilmente intuibile anche dal semplice numero di righe, la struttura del server è diventata
banale grazie alle chiamate alle API di polly.
Entry point | Funzione |
---|---|
/register |
recupera le sessioni attuali (per mostrare gli utenti collegati alla chat) e registra una nuova sessione anche grazie allo username ricevuto dal client |
/hold |
mette la chiamata in attesa di nuovi eventi |
/push |
crea un nuovo evento "message" a partire dal messaggio (req.params.message ) ricevuto dal client |
/part |
elimina la sessione attuale |
Polly, il componente client-side
Gli aspetti client-side dell'applicazione sono notevolmente più semplici. Il modulo polly-client implementa le seguenti funzioni:
init: function(registerUrl, holdUrl, pushUrl, partUrl eventHandler) {
polly.registerUrl = registerUrl;
polly.holdUrl = holdUrl;
polly.eventHandler = eventHandler;
polly.pushUrl = pushUrl;
polly.partUrl = partUrl;
polly.lastEventId = -1;
},
register: function(data, cb) {
$.getJSON(polly.registerUrl, data,function(data) {
if(data.status) {
polly.hold();
cb(data);
}
});
},
hold: function() {
$.getJSON(polly.holdUrl, {
lastEventId: polly.lastEventId
},
function(data) {
if(data.status) {
var events = data.events;
if(events && events.length > 0)
polly.lastEventId = events[events.length-1].id;
polly.hold();
for(i in events) {
var event = events[i];
var handler = polly.eventHandler[event.type];
if(handler && typeof handler == "function") {
handler(event);
}
}
}
});
},
pushEvent: function(eventType, data) {
data.eventType = eventType;
$.getJSON(polly.pushUrl, data);
},
part: function(cb) {
$.getJSON(polly.partUrl, function(data) {
if(data.status) {
cb();
}
});
}
Il costruttore init
imposta gli url dei vari entry point e definisce un particolare eventHandler che delega all'utente il comportamento dell'interfaccia allo scatenarsi di eventi.
I metodi register
, pushEvent
e part
notificano al server eventuali nuovi eventi mentre il metodo hold
inizializza la chiamata asincrona e ne gestisce la risposta identificando l'id dell'ultimo evento (che viene salvato nella property lastEventId
) e per ogni evento ricevuto invoca il rispettivo handler impostato dall'utente nel costruttore.
Il file index.html
L'unico aspetto interessante legato all'implementazione client-side della chat è rappresentata dal costruttore init in quanto tutti gli altri aspetti riguardano modifiche all'interfaccia e non verranno approfondite in questo articolo.
polly.init('/register', '/hold', '/push', '/part', {
"message": function(event) {
var message = "<b>"+event.user.username+"</b>: "+event.data.message;
writeMessage(message, "black");
},
"user_join": function(event) {
var message = event.user.username+" join the chat...";
writeMessage(message, "green");
appendUser(event.user);
},
"user_part": function(event) {
var message = event.user.username+" parts...";
writeMessage(message, "grey");
removeUser(event.user);
}
});
Una volta impostate le url dei vari entry point, viene creato un eventHandler per la gestione dei vari eventi. Un eventHandler non è nient'altro che una mappa di funzioni identificate dal nome dell'evento che le deve scatenare.
Conclusioni
Abbiamo approfondito ulteriormente il pattern di comunicazione long-polling introducendo le basi per la realizzazione di un modulo NodeJS. Applicazioni di questo tipo presentano una serie di aspetti comuni e questa è la strada giusta da seguire. Ovviamente lo scopo dell'articolo era principalmente formativo quindi non sono stati introdutti una serie di aspetti legati alla gestione dell'interfaccia in HTML.
Polly nonostante per l'esempio funzioni egregiamente non è ancora pronto per applicazioni reali soprattutto per la mancanza di alcuni comportamenti, come ad esempio la possibilità di notificare eventi solo a determinati utenti (ora tutti gli eventi scatenati vengono inviati a tutti gli utenti collegati) oppure una gestione maggiormente oculata sulla sicurezza o sull'affidabilità (per esempio manca un meccanismo di try/catch
per impedire che il server rimanga sempre attivo).
Tutti i sorgenti sono allegati all'articolo. Nella prossima puntanta introdurremo il pattern per applicazioni in real-time basato su WebSocket.