#229 Polling for Changes
- Download:
- source codeProject Files in Zip (107 KB)
- mp4Full Size H.264 Video (24.3 MB)
- m4vSmaller H.264 Video (16.5 MB)
- webmFull Size VP8 Video (41.4 MB)
- ogvFull Size Theora Video (34.3 MB)
Si immagini di avere un’applicazione di blogging che consenta ai suoi utenti di aggiungere commenti agli articoli. Il blog è molto popolare, per cui vengono aggiunti nuovi commenti abbastanza di frequente. Se qualcuno arriva sul sito per leggere un articolo e i suoi commenti e poi decide di aggiungere il proprio commento, è possibile che nel momento che intercorre fra il suo arrivo sul sito e il suo commento, altri nuovi commenti siano nel frattempo stati aggiunti all’articolo, rendendo potenzialmente il commento che quell’utente voleva inserire scorretto o irrilevante. Perciò sarebbe utile che la pagina dell’articolo si mantenesse aggiornata automaticamente, aggiungendo, sempre in automatico, tutti i nuovi commenti aggiunti a seguito dell’ultimo caricamento della pagina. Vi mostreremo come farlo in questo episodio.
Configurazione di jQuery
Dovremo usare del JavaScript per aggiornare la pagina al volo ed in particolare useremo jQuery al posto di Prototype come nostra libreria JavaScript preferita. Dal momento che la nostra applicazione è scritta in Rails 3, possiamo utilizzare il gem jquery-rails
per rendere semplice la sostituzione dei file di Prototype con quelli di jQuery.
Per usare il gem, dobbiamo semplicemente aggiungere un riferimento ad esso nel Gemfile
della nostra applicazione:
gem 'jquery-rails'
Poi possiamo fare in modo che il tutto sia scaricato e installato lanciando:
$ bundle install
Dopo che Bundler avrà finito il suo lavoro, sarà disponibile un nuovo generatore che potremo lanciare per configurare jQuery nella nostra applicazione. Quando lo lanciamo, questi rimuoverà i file di Prototype, aggiungerà quelli di jQuery e infine sovrascriverà il file di default rails.js
della nostra applicazione con una versione dello stesso compatibile con jQuery (se volete installare la libreria UI di jQuery insieme a jQuery, potete passare l’opzione --ui
al generatore):
$ rails g jquery:install <span class="passed">remove</span> public/javascripts/controls.js <span class="passed">remove</span> public/javascripts/dragdrop.js <span class="passed">remove</span> public/javascripts/effects.js <span class="passed">remove</span> public/javascripts/prototype.js <span class="passed">create</span> public/javascripts/jquery.min.js <span class="passed">create</span> public/javascripts/jquery.js <span class="failed">conflict</span> public/javascripts/rails.js Overwrite /Users/asalicetti/rails/apps_for_asciicasts/ep229/blog/public/javascripts/rails.js? (enter "h" for help) [Ynaqdh] Y <span class="forced">force</span> public/javascripts/rails.js
Una delle cose più utili del gem jquery-rails è che ridefinisce in automatico i default che sono inclusi quando si usa la linea:
<%= javascript_include_tag :defaults %>
nel nostro file di layout, per cui non c’è bisogno di fare modifiche là.
Configurare Polling
Ora che abbiamo configurato jQuery nella nostra applicazione, possiamo cominciare a fare il lavoro necessario per l’auto-aggiornamento dei commenti. Ci sono due modi in cui possiamo affrontare il problema. Uno prevede l’utilizzo del polling. La nostra applicazione può inviare delle richieste AJAX al server a periodi di tempo regolari per capire se il numero dei commenti è cambiato. L’alternativa è di usare WebSockets per fornire una connessione permanente al server e ricevere in push le notifiche da esso non appena sono disponibili. Il polling è la scelta più semplice fra le due, considerato anche che WebSockets non è ancora supportato da tutti i browser e che necessiterebbe della stesura di una maggior quantità di codice e introdurrebbe anche una nuova dipendenza con una libreria di terzi, tipo Cramp per funzionare. Non abbiamo bisogno di un sincronismo così stretto per il nostro caso, per cui un ritardo di qualche secondo è perfettamente accettabile e per questo seguiremo l’approccio a polling.
Scriveremo il JavaScript che si occuperà di fare le richieste in polling alla ricerca di cambiamenti nel file application.js
. Questo file è presente in tutte le pagine dell’applicazione, per cui dovremo anche assicurarci che lo stesso esegua solamente nelle pagine che contengono commenti al loro interno. Il codice per il polling è il seguente:
$(function () { if ($('#comments').length > 0) { setTimeout(updateComments, 10000); } }); function updateComments() { $.getScript('/comments.js'); setTimeout(updateComments, 10000); }
Il codice riportato qui sopra comincia con una chiamata alla funzione jQuery $
. Quando questa funzione viene chiamata con un argomento di tipo funzione, come in questo caso, il codice all’interno della funzione esegue solo dopo che il DOM della pagina è stato caricato completamente. In questo caso, quando il DOM si carica, controlliamo se la pagina corrente è quella di un articolo, cercando la presenza di un elemento con id
uguale a comments
. Se lo si trova, allora usiamo la setTimeout
per chiamare la funzione updateComments
dopo un ritardo di dieci secondi.
All’interno della funzione updateComments
vogliamo recuperare tutti i commenti aggiornati per l’articolo. Ci sono molti modi per farlo. Potremmo chiedere al server i nuovi commenti in formato JSON e usarli per creare l’HTML per i nuovi commenti, ma in questo caso il problema sarebbe che la updateComments
dovrebbe poi generare l’HTML corretto per questi nuovi commenti. Sarebbe più semplice generare l’HTML già lato server e restituire il JavaScript che aggiornerà la pagina per aggiungere tale HTML al posto giusto.
Per fare tutto questo, usiamo la funzione jQuery getScript
. Se passiamo a questa funzione un URL, essa farà una richiesta AJAX a tale URL ed eseguirà qualsiasi script che le verrà restituito in risposta. Useremo dunque la getScript
per chiamare la action index
del CommentsController
con un formato JavaScript. Dobbiamo aggiungere alcuni parametri di query all’URL affinchè la action sappia quali commenti dovrebbero esserci restituiti, ma ritorneremo su questo punto in seguito.
Dopo che la getScript
ha completato la sua esecuzione, invochiamo nuovamente la setTimeout
, affinchè la updateComments
sia nuovamente chiamata dopo dieci secondi. Avremmo potuto usare la setInterval
al posto della setTimeout
per il controllo dell’esistenza dell’elemento dei commenti, in modo tale da non dover richiamare la funzione anche qui, ma in realtà c’è un vantaggio nel seguire il nostro approccio. Se avessimo utilizzato la setInterval
, di conseguenza la updateComments
sarebbe stata chiamata ogni dieci secondi a prescindere dal tempo necessario alla risposta AJAX. Invece, per come l’abbiamo implementata noi, tale richiesta di aggiornamento verrà fatta sempre dieci secondi dopo che ci è arrivata la risposta: in questo modo evitiamo di sovraccaricare ulteriormente il server quando è già molto carico di suo.
Al momento, il CommentsController
non ha una action index
, per cui dovremo scriverla. Dovrà recuperare gli opportuni commenti usando i parametri passati dal JavaScript di prima, ma per ora lasciamo vuoto il metodo:
def index end
Dovremo anche avere un template JavaScript da associare alla action. In esso, sempre momentaneamente, metteremo del codice di test per controllare che il polling stia funzionando a dovere:
alert('Comments go here');
Facendo ripartire il server e visitando la pagina dell’articolo, ora vedremo comparire un alert circa ogni dieci secondi dopo che la pagina si è caricata, che significa che il JavaScript di sopra viene restituito dal server ed eseguito correttamente sul client:
Recupero dei commenti corretti
Ora che il polling funziona, il prossimo passo è quello di recuperare i commenti significativi. Dobbiamo aggiungere due parametri alla chiamata della action index
del CommentController
: l’id
dell’articolo corrente e un timestamp, in modo tale che sappiamo quali commenti recuperare:
function updateComments() { $.getScript('/comments.js?article_id=' + article_id + "&after=" + after); setTimeout(updateComments, 10000); }
Ciò che dobbiamo ancora capire del codice sopra è da dove prendere i valori per le variabili article_id
e after
, che non abbiamo ancora definito. In questo caso possiamo generare i valori in Rails e passarli al JavaScript dal documento HTML. Questa applicazione usa HTML 5, per cui possiamo utilizzare gli attributi data per aggiungere dati al documento.
Aggiungeremo gli attributi al codice della vista relativa alla action show
del ArticleController
. Gli attributi data possono avere un qualsiasi nome, purchè iniziante per data-
, per cui aggiungeremo un attributo data-id
al div
contenitore dell’articolo, con valore pari all’id
dell’articolo e in modo analogo, nel div
contenitore dei commenti, aggiungeremo un nuovo attributo data-time
, contenente il valore created_at
del commento, convertito a intero:
<% title @article.name %> <div id="article" data-id="<%= @article.id %>"> <%= simple_format @article.content %> <p><%= link_to "Back to Articles", articles_path %></p> <% unless @article.comments.empty? %> <h2><%= pluralize(@article.comments.size, 'commment') %></h2> <div id="comments"> <% for comment in @article.comments %> <div class="comment" data-time="<%= comment.created_at.to_i %>"> <strong><%= comment.name %></strong> <em>on <%= comment.created_at.strftime('%b %d, %Y at %I:%M %p') %></em> <%= simple_format comment.content %> </div> <% end %> </div> <% end %> <h3>Add your comment</h3> <%= render :partial => 'comments/form' %> </div>
Possiamo ora ritornare alla funzione updateComments
e impostare i valori delle variabili article_id
e after
da quegli attributi data. Ottenere l’id
dell’articolo è semplice: lo prendiamo banalmente dall’elemento che ha id
uguale a article
. Ci potrebbero invece essere molti commenti nella pagina e ciò che ci occorre è solo l’ultimo, per cui useremo il selettore .comment:last
per identificarlo, dopodichè leggeremo il suo attributo data-time
:
function updateComments() { var article_id = $('#article').attr('data-id'); var after = $('.comment:last').attr('data-time'); $.getScript('/comments.js?article_id=' + article_id + "&after=" + after); setTimeout(updateComments, 10000); }
Ora che abbiamo a disposizione i valori per entrambe le variabili, possiamo usarli per recuparere i soli commenti che sono stati creati per quell’articolo dopo la data dell’ultimo presente in pagina.
E’ una buona idea quando si fa qualcosa di simile, controllare i log di sviluppo per accertarsi che tutto sembri funzionare come dovrebbe. Se visitiamo la pagina degli articoli e poi guardiamo i log, vedremo che la richiesta per i commenti in JavaScript viene fatta ogni dieci secondi più o meno, come ci aspettavamo a anche i parametri sembrano a posto:
Started GET "/comments.js?article_id=1&after=1283173233" for 127.0.0.1 at 2010-09-02 19:58:53 +0100 Processing by CommentsController#index as JS Parameters: {"article_id"=>"1", "after"=>"1283173233"} Rendered comments/index.js.erb (0.4ms) Completed 200 OK in 41ms (Views: 41.1ms | ActiveRecord: 0.0ms) Started GET "/comments.js?article_id=1&after=1283173233" for 127.0.0.1 at 2010-09-02 19:59:07 +0100 Processing by CommentsController#index as JS Parameters: {"article_id"=>"1", "after"=>"1283173233"} Rendered comments/index.js.erb (0.4ms) Completed 200 OK in 34ms (Views: 33.6ms | ActiveRecord: 0.0ms)
ora che sappiamo che i parametri sono passati correttamente, possiamo usarli nella action index
del CommentsController
per avere i nuovi commenti:
def index @comments = Comment.where("article_id = ? and created_at > ?", params[:article_id], Time.at(params[:after].to_i)) end
Possiamo ora aggiornare il template associato al JavaScript affinchè mostri i commenti anzichè mostrare solo un alert. Prima di farlo, comunque, dobbiamo spostare il codice che mostra un commento fuori dalla vista show
dell’articolo, in un partial separato, in modo tale che lo si possa usare nel template del Javascript. Muoveremo dunque tale codice in un nuovo file partial chiamato _comment.html.erb
:
<div class="comment" data-time="<%= comment.created_at.to_i %>"> <strong><%= comment.name %></strong> <em>on <%= comment.created_at.strftime('%b %d, %Y at %I:%M %p') %></em> <%= simple_format comment.content %> </div>
Dopodichè, nella vista relativa alla action show
, possiamo semplicemente renderizzare il partial per la collezione di commenti:
<% title @article.name %> <div id="article" data-id="<%= @article.id %>"> <%= simple_format @article.content %> <p><%= link_to "Back to Articles", articles_path %></p> <% unless @article.comments.empty? %> <h2><%= pluralize(@article.comments.size, 'commment') %></h2> <div id="comments"> <%= render @article.comments %> </div> <% end %> <h3>Add your comment</h3> <%= render :partial => 'comments/form' %> </div>
Possiamo aggiornare il template JavaScript della pagina index
dei commenti affinchè generi il JavaScript per aggiornare i commenti:
$('#comments').append("<%= escape_javascript(raw render(@comments))%>");
Questo codice renderizza la collezione di commenti usando il partial che abbiamo appena scritto. Dobbiamo racchiudere l’output del partial in un metodo raw
, poichè per default Rails 3 farebbe l’escape dell’HTML e noi non lo vogliamo. Poi dobbiamo chiamare l’escape_javascript
sull’output di quel comando, affinchè l’HTML sia sicuro per essere racchiuso in una stringa JavaScript. Il JavaScript che viene rimandato indietro al browser aggiunge questa stringa in fondo al div
dei commenti.
Possiamo dare un’occhiata alla pagina nel browser, ora, per vedere se i commenti si aggiornano automaticamente ed otteniamo dei risultati solo parziali. Sebbene i commenti siano aggiornati, continuiamo a vedere che l’ultimo commento continua ad essere aggiunto:
Ciò avviene perchè quando otteniamo i nuovi commenti, il timestamp che passiamo ha una precisione al secondo. In questo caso stiamo chiedendo i commenti posteriori alle 13:00:33 del 30 agosto 2010, ma se guardiamo sul database, il commento è stato inserito alle 13:00:33.892041 e, anche se apparentemente dei millisecondi non ce ne potrebbe fregare di meno, per il controllo che facciamo, questa minima differenza è sufficiente a far considerare il commento sul database posteriore come timestamp a quello passato come parametro in query. Per sistemare questa cosa, dobbiamo arrotondare e chiedere i commenti lasciati almeno un secondo dopo l’ultimo commento attuale, per cui aggiungiamo 1
al valore intero ricevuto dal parametro nella query:
def index @comments = Comment.where("article_id = ? and created_at > ?", params[:article_id], Time.at(params[:after].to_i + 1)) end
Ora non vedremo più l’ultimo commento comparire di continuo. Possiamo testare che l’aggiornamento funziona, aprendo la pagina degli articoli su due finestre del browser differenti, inserendo un commento in una e controllando che appaia anche nell’altra:
Dopo pochi secondi il polling interviene e il commento compare sull’altra finestra del browser.
Aggiornamento del contatore dei commenti
Anche se il nuovo commento è stato correttamente aggiunto per polling, la testata che mostra il numero totale dei commenti non è stata aggiornata di conseguenza. Sistemiamo la cosa:
Possiamo aggiornare il contatore dei commenti nello stesso file utilizzato anche per l’aggiornamento dei commenti stessi. Di primo impatto, la cosa ovvia da fare potrebbe essere qualcosa del genere:
$('#comments').append("<%= escape_javascript(raw render(@comments))%>"); $('#article h2').text('<%= @comments.first.article.comments.size %> comments');
Questo approccio implica tuttavia una chiamata al database per ottenere il totale dei commenti e dal momento che questo codice sarà chiamato piuttosto frequentemente, ha senso cercare di evitarlo, se possiamo. Possiamo ottenere il totale dei commenti direttamente dalla pagina, banalmente contanto il numero di elementi di classe comment
presenti sulla pagina stessa:
$('#comments').append("<%= escape_javascript(raw render(@comments))%>");
$('#article h2').text($('.comment').length + ' comments');
Questo codice non gestirà la pluralizzazione, per cui mostrerà sempre la dicitura “commenti”, anche se c’è solo un commento, ma tralasciamo questo particolare. Come tocco finale, aggiungeremo una clausola affinchè il JavaScript sia impostato solo se ci sono dei nuovi commenti:
<% unless @comments.empty? %> $('#comments').append("<%= escape_javascript(raw render(@comments))%>"); $('#article h2').text($('.comment').length + ' comments'); <% end %>
Quando aggiungiamo un altro commento in un browser, ora, i commenti vengono aggiornati anche nell’altra finestra del browser, aperta sulla stessa pagina, insieme al contatore:
Un’ultima correzione
C’è solo ancora un piccolo problema con questo codice che deve essere sistemato. Nella funzione updateComments
, il valore della variabile after
è impostata da un attributo preso dall’ultimo commento presente sulla pagina. Se non ci sono commenti per un certo articolo, allora questo valore sarà vuoto, per cui dovremo fare in modo che la variabile sia associata, in questi casi, al valore di default di 0, affinchè siano restituiti tutti i commenti:
$(function () { if ($('#comments').length > 0) { setTimeout(updateComments, 10000); } }); function updateComments() { var article_id = $('#article').attr('data-id'); if ($('.comment').length > 0) { var after = $('.comment:last').attr('data-time'); } else { var after = 0; } $.getScript('/comments.js?article_id=' + article_id + "&after=" + after); setTimeout(updateComments, 10000); }
Ci sono altre cose che potrebbero essere migliorate. Per esempio, potremmo rendere l’intervallo di aggiornameno dinamico, affinchè, se l’ultimo commento è stato aggiunto molto tempo fa, diciamo per esempio più di un’ora fa, la frequenza del polling sia ridotta. In questo modo si riduce il carico sul server quando si osservano articoli che non hanno avuto commenti di recente.
Potremmo anche migliorare un po’ l’interfaccia utente, aggiungendo un evidenziazione, per indicare quando viene aggiunto un nuovo commento alla pagina. In altrenativa, potremmo aggiungere un link che indica che ci sono nuovi commenti disponibili e che li mostra solamente al click dell’utente sullo stesso.