#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)
Supongamos que tenemos una aplicación de blog que permite a los usuarios añadir comentarios en los artículos. Este blog es muy popular por lo que los comentarios se añaden con mucha frecuencia. Si alguien viene al sitio a leer un artículo y decide comentar algo es posible que para cuando vaya a añadir su comentario ya se hayan publicado otros comentarios que hagan que su comentario sea incorrecto o irrelevante. Sería útil, por tanto, que la página se actualizase automáticamente mostrando los nuevos comentarios según se vayan publicando. Veremos cómo se puede hacer esto en este episodio.
Configuración de jQuery
Vamos a tener que usar JavaScript para la actualización de la página al vuelo, y como es habitual vamos a usar jQuery en lugar de la librería Prototype que viene incluida en Rails. Como se trata de una aplicación Rails 3podemos usar la gema jquery-rails para reemplazar fácilmente los archivos de Prototype por los de jQuery.
Para utilizar la gema tan sólo tenemos que referenciarla desde el Gemfile
de nuestra aplicación.
gem 'jquery-rails'
Podemos configurarla usando
$ bundle install
Cuando Bundler termine su trabajo tendremos a nuestra disposición un nuevo generador que podemos ejecutar para configurar nuestra aplicación para usar jQuery. Si lo ejecutamos eliminará los archivos de Prototype e instalará los de jQuery y luego sobreescribirá el fichero rails.js
por defecto de Rails (que usa Prototype) por uno adaptado a jQuery (si queremos instalar también la librería jQuery UI podemos pasarle la opción --ui
para que instale también estos archivos)
$ 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/eifion/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 de las cosas más útiles de la gema jquery-rails es que sobreescribe los valores por defecto que se generan cuando utilizamos la línea
<%= javascript_include_tag :defaults %>
en nuestro fichero de lyaout por lo que aquí no tendremos que hacer ningún cambio.
Configuración de la consulta periódica
Una vez que hemos configurado jQuery en nuestra aplicación podemos empezar a hacer lo que necesitamos para que los comentarios se actualicen automáticamente. Podemos enfocar el problema de dos formas. Una de ella es hacer una consulta periódica (polling). Nuestra aplicación puede, a intervalos regulares enviar una petición AJAX al servidor para ver si ha cambiado el número de comentarios. La otra opción es utilizar WebSockets para tener una conexión permanente con el servidor y enviar los cambios al usuario tan pronto como estén disponibles. La más fácil es la primera porque los WebSockets todavía no están soportados por todos los navegadores y nos exigiría además trabajar con una librería como Cramp. Como nos parece aceptable tener un retraso de un par de segundos cuando se publique un nuevo comentario, aplicaremos la técnica de la consulta periódica.
Escribiremos el JavaScript que realiza la consulta periódica en el fichero application.js
de la aplicación. Este fichero se incluye en todas las páginas de la aplicación por lo que nos tocará asegurarnos de que el código que busca si hay comentarios actualizados sólo se ejecuta en las páginas que muestren comentarios. El código de la consulta periódica tiene el siguiente aspecto:
$(function () { if ($('#comments').length > 0) { setTimeout(updateComments, 10000); } }); function updateComments() { $.getScript('/comments.js'); setTimeout(updateComments, 10000); }
El código comienza con una llamada a la función $
de jQuery. Cuando se invoca esta función pasando como argumento una función (como es el caso) el código dentro de la función se ejecutará cuando se haya cargado el DOM de la página. En este caso lo que queremos que ocurre es comprobar si estamos en una página de artículo buscando un elemento con id
llamado comments
, y si lo encontramos usamos setTimeout
para llamar a la función updateComments
después de esperar diez segundos.
En la función updateComments
queremos recibir si los comentarios que se hayan actualizado en el articulo, y hay varias formas de hacerlo. Podríamos hacer que el servidor devolviese los nuevos comentarios en formato JSON y utilizar dichos datos para crear el HTML de los nuevos comentarios, pero el problema con esto es que updateComments
debería generar el HTML correcto para los nuevos comentarios. Sería más fácil si generásemos el HTML en el servidor y enviásemos un JavaScript que actualizase la página para añadir dicho HTML en su sitio.
Para hacer esto utilizaremos la función getScript
de jQuery. Si le pasamos a esta función una URL realizará una petición AJAX a la misma y ejecutará cualquier script que le sea devuelto. Utilizaremos getScript
para llamar al método index
de CommentsController
con formato JavaScript. Tendremos también que añadir algún que otro parámetro a la URL para que podamos decir cuántos comentarios se deben devolver, pero ya regresaremos a esto más adelante.
Cuando getScript
haya terminado llamaremos nuevamente a setTimeout
para que se ejecute nuevamente la función updateComments
a los diez segundos. Podríamos haber usado setInterval
en lugar de setTimeout
para no tener que estar lanzando el temporizador una y otra vez, pero hacerlo así tiene una ventaja, y es que si utilizásemos setInterval
entonces se invocaría updateComments
cada diez segundos independientemente de lo que tardase su ejecución, de esta forma la petición se hará diez segundos después de que la función haya terminado de forma que no causaremos una carga demasiado elevada en el servidor cuando esté recibiendo mucho tráfico.
CommentsController
todavía no tiene acción index
por lo que tendremos que escribirla. Tendrá que recuperar los comentarios utilizando los parámetros que reciba del código JavaScript, pero por ahora la dejaremos en blanco.
def index end
Necesitaremos una plantilla de JavaScript con la acción. En ella tan sólo pondremos un poco de código de pruebas para asegurarnos de que la consulta funciona.
alert('Comments go here');
Si arrancamos el servidor de la aplicación y visitamos la página de un artículo veremos que aparece la alerta al cabo de diez segundos tras la carga de la página, lo que quiere decir que el JavaScript devuelto se está ejecutando en el cliente.
Recuperación de los comentarios
Ahora que ya tenemos los comentarios funcionando el siguiente paso es recuperar los comentarios relevantes. Tan sólo tenemos que añadir dos parámetros a la acción index
de CommentController
: el id
del artículo actual y una marca de tiempo para que sepamos qué comentarios debemos recuperar.
function updateComments() { $.getScript('/comments.js?article_id=' + article_id + "&after=" + after); setTimeout(updateComments, 10000); }
Lo que todavía nos queda pendiente en el código anterior es de dónde obtener los valores de las variables article_id
y after
que todavía no hemos definido. En este caso podemos generar los valores en Rails y pasárselos a JavaScript mediante el documento HTML. Esta aplicación utiliza HTML5 por lo que podemos usar atributos de datos para añadir estos datos al documento
Vamos a añadir los atributos en el código de la vista de la acción show
de ArticleController
. Los atributos de datos pueden tener el nombre que queramos siempre que empiecen por data-
por lo que añadiremos un atributo data-id
al div
que rodea al artículo y le daremos el valor del id
del atributo. Similarmente en el div
que rodea a cada comentario añadiremos un atributo data-time
que contiene el valor de created_at
para cada comentario (convertido a un entero)
<% 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>
Ahora podemos volver a la función updateComments
y establecer los valores de las variables article_id
y after
a partir de estos atributos de datos. La recuperación del id
del artículo es inmediata: tan sólo tenemos que cogerla del elemento cuyo id
sea article
. Podría haber varios comentarios en la página y queremos encontrar el último por lo que tendremos que usar el selector .comment:last
para recuperarlo y luego leer su atributo 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); }
Ahora que ya tenemos valores para estas dos variables podemos usarlas para recuperar sólo los comentarios que se hayan creado para este artículo después del instante de tiempo del último comentario que se esté mostrando en la página.
Cuando hacemos cosas como esta es buena idea comprobar el log de desarrollo para estar seguros de que todo funciona como debería. Si visitamos la página de artículos y luego miramos el log veremos que la petición para el JavaScript de comentarios se está haciendo cada diez segundos más o menos como era de esperar y también parece que los parámetros están correctos.
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)
Ahora que sabemos que los parámetros se pasan correctamente podemos utilizarlos en la acción
index
de CommentsController
para recuperar nuevos comentarios.
def index @comments = Comment.where("article_id = ? and created_at > ?", params[:article_id], Time.at(params[:after].to_i)) end
Ya podemos cambiar la plantilla JavaScript para que muestre los comentarios en lugar de sacar una alerta. Pero antes de eso tenemos que quitar el código que muestra el comentario de la vista show
del artículo y ponerlo en un parcial para poder usarlo en la plantilla JavaScript. El parcial será _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>
En la acción show
podemos simplemente mostrar el parcial para la colección de comentarios:
<% 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>
Ya podemos cambiar la plantilla JavaScript de index
de comentarios para que genere el código que actualice los comentarios:
$('#comments').append("<%= escape_javascript(raw render(@comments))%>");
Este código saca la colección de comentarios utilizando el parcial que acabamos de escribir. Tenemos que rodear la salida del parcial con el método raw
porque por defecto Rails 3 escapará el HTML (cosa que no queremos que ocurra). Luego tenemos que llamar a escape_javascript
sobre la salida de ese comando para que HTML pueda ser embebido en una cadena JavaScript. El JavaScript que se envía al navegador tomará esta cadena y la insertará al final del div
de comentarios.
Podemos mirar ahora la página con el navegador para ver si los comentarios se actualizan automáticamente, y comprobamos que tenemos un éxito parcial: aunque los comentarios se actualizan vemos que lo que se añade una vez tras otra es el último comentario.
Esto ocurre porque cuando recuperamos los últimos comentarios la marca de tiempo que pasamos sólo tiene precisión hasta el segundo. En este caso estamos recuperando los comentarios después de las 13:00:33 del 30 de Agosto de 2010, pero si miramos en la base de datos veremos que el comentario se insertó exactamente a las 13:00:33.892041. Si bien no es una diferencia apreciable es suficiente como para que se considere que fue después de la fecha que estamos consulta. Para corregir esto tenemos que redondear hacia arriba para recuperar los comentarios al menos un segundo después del comentario actual, por lo que le sumaremos 1 al valor entero recibido como parámetro de la consulta.
def index @comments = Comment.where("article_id = ? and created_at > ?", params[:article_id], Time.at(params[:after].to_i + 1)) end
Ahora veremos que el último comentario no se repite una vez tras otra. Podemos ver que la actualización funciona abriendo la página del artículo en dos ventanas del navegador, enviando un comentario en una de ellas y viendo que aparece en la otra.
Tras unos segundos tiene lugar el refresco automático y aparece el comentario en la otra ventana del navegador.
Actualización del total
Aunque se ha añadido el nuevo comentario, el encabezado que muestra el número de comentarios totales no se ha actualizado de manera acorde. Corrijámoslo.
Podemos actualizar la cuenta de comentarios en el mismo archivo que usamos para actualizar los comentarios. Lo obvio sería hacer algo como:
$('#comments').append("<%= escape_javascript(raw render(@comments))%>"); $('#article h2').text('<%= @comments.first.article.comments.size %> comments');
Sin embargo este código realizará una llamada a la base de datos para recuperar el numero de comentarios, y dado que este código será llamado con mucha frecuencia tiene sentido evitar esto si podemos. Lo que podemos hacer es contar el número de comentarios que hay en la página viendo cuántos elementos hay que tengan la clase comment
.
$('#comments').append("<%= escape_javascript(raw render(@comments))%>");
$('#article h2').text($('.comment').length + ' comments');
No nos preocuparemos de que este código no gestiona la pluralización adecuadamente, por lo que sólo mostrará el literal “comments” incluso si sólo tenemos un comentario. Como toque final añadiremos una cláusula para que sólo se envíe el JavaScript si existen comentarios.
<% unless @comments.empty? %> $('#comments').append("<%= escape_javascript(raw render(@comments))%>"); $('#article h2').text($('.comment').length + ' comments'); <% end %>
Si ahora añadimos un comentario en una ventana del navegador veremos que dicho comentario y el contador total se actualiza en la otra ventana.
Corrección final
Todavía queda un pequeño problema en nuestro código que hemos de arreglar. En la función updateComments
elvalor de la variable after
se establece a partir de un atributo del último comentario de la página. Si no hay comentarios para el artículo que está siendo visualizado entonces este valor estará vacío por lo que en este caso estableceremos un valor por defecto de cero para que se devuelvan todos los comentarios.
$(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 { h var after = 0; } $.getScript('/comments.js?article_id=' + article_id + "&after=" + after); setTimeout(updateComments, 10000); }
Hay otras mejores que podríamos hacer en esta parte del código, por ejemplo podríamos hacer que el intervalo de refresco fuese dinámico de forma que si el último comentario se añadió hace mucho tiempo (por ejemplo una hora) la frecuencia de consulta se redujese, y así impondríamos una menor carga en el servidor cuando se visualicen artículos donde no haya habido comentarios recientemente.
También podríamos mejorar un poco la interfaz añadiendo un efecto de resaltado para indicar cuando aparecen nuevos comentarios en la página. También podríamos, alternativamente, añadir un enlace que dijese que han aparecido nuevos comentarios y sólo mostrarlos cuando se haga clic sobre él.