Aprendiendo RabbitMQ con rails

RabbitMQ es un sistema de mensajería también conocido como broker de mensajería. Se compone principalmente por un emisor(producer) y uno o más consumidores(consumers), usando mensajes para comunicarse, estos pueden contener desde un texto simple hasta métodos o funciones que realizen procesos más complejos.

Este tipo de sistemas destacan por la facilidad de administrar diferentes tipos de tareas haciéndolo de una forma organizada y distribuida dentro de nuestra aplicación o en varias applicaciones, sin importar el lenguaje de programación o como hayan sido construidas, basta con tener un cliente el cual se comunique con nuestro servidor de RabbitMQ para que funcione, tareas como notificar cambios entre sistemas o realizar procesos de manera asíncrona son algunas de sus especialidades.

Existen varios componentes que necesitamos conocer para que RabbitMQ funcione como esperamos, revisemos rápidamente sus responsabilidades:

  1. Producer: El encargado de emitir mensajes a los exchanges.

  2. Exchanger: Se encarga de dirigir los mensajes, dicho de otra forma, deciden la manera en como se van a propagar o distribuir. Existen varias formas:

    • Direct. Esta forma distribuye los mensajes a todas las queues, pero solo los queues que tengan un identificador idéntico realizaran la acción, hacen uso de los atributos routing_key en los exchanges y binding_key en los queues para hacer la comparación.
    • Fanout. Se distribuyen a todas las queues sin necesidad de algún identificador para que ejecuten su acción.
    • Topic. Parecido a direct, pero no tiene que existir un identificador que sea idéntico, bastara que el routing_key esté contenido dentro del routing_pattern que es una expresión regular. En este tipo de notificación los routing_keys suelen ser palabras separadas por puntos y comas.
    • Headers. Similar al Topic pero en este caso se hace la comparación con headers, en un estilo de {“key”, “value”}.
  3. Queue: Guarda los mensajes para después ser ejecutados. Pueden existir uno o mas queues.

  4. Consumer: Recibe los mensajes del queue, y su principal función es la de procesar la tarea solicitada, aquí tendremos el código que queremos ejecutar.

  5. Channel: Por último, todos los actores anteriores necesitan de un medio para comunicarse, aquí entran en juego los canales que son las líneas de conexión entre el producer y el consumer, dicho de otra forma, sin un channel un producer no podría comunicarse con un consumer para procesar una acción.

Puedes verlo de forma mas visual en el siguiente diagrama:

RabbitMQ-BlogpostDiagram

Bien, ahora que todo esta aclarado, es momento de ensuciarnos las manos un poco y ponerlo a funcionar usando Rails.

Cómo instalar rabbitmq?

Comenzaremos con la instalación de RabbitMQ, este es un sistema aparte que corre como un proceso separado al de Rails, y poco tiene que ver con Ruby, así que la mejor forma de instalarlo es desde su pagina oficial aquí: RabbitMQ

Una vez que tengamos RabbitMQ instalado, necesitamos un cliente de Ruby que nos de accesso a este proceso independiente, para esto instalaremos la gema oficial bunny aqui: bunny

Hay que asegurarnos que están funcionando juntos, para ello, vamos a crear un archivo de ruby y nos conectaremos a RabbitMQ, como el siguiente:

require "bunny"

conn = Bunny.new
conn.start
ch = conn.create_channel

q = ch.queue("test1")
q.publish("Hello, everybody!")
q.subscribe(:block => true) do |delivery_info, properties, payload|
 puts(payload)
end

Cuando ejecutemos este código, deberías de poder ver el mensaje de Hello World con esto comprobaremos que tenemos funcionando RabbitMQ y estamos listos para continuar.

Cómo usarlo en rails?

Porque hacerlo en archivos planos de ruby no es divertido, hagamos uso de todo lo que aprendimos y usemos estos conceptos en una aplicacion sencilla como un chat, aunque no sea el mejor ejemplo, nos ayudara a jugar mas con RabbitMQ.

nota: Ya que RabbitMQ esta hecho para funcionar como un proceso del servidor, no hay forma de usarlo directamente en el navegador, asī que vamos a usar un servicio que nos ayude a mandar los mensajes desde el servidor hacia nuestra aplicación de chat: pusher

Ahora si manos a la obra, comencemos agregando la gema de pusher a nuestro proyecto y creamos el archivo de configuración desde un initializer config/initializers/pusher.rb agreguemos:

require 'pusher'

Pusher.app_id = ENV['PUSHER_APP_ID']
Pusher.key = ENV['PUSHER_KEY']
Pusher.secret = ENV['PUSHER_SECRET']
Pusher.cluster = ENV['PUSHER_CLUSTER']
Pusher.logger = Rails.logger
Pusher.encrypted = true

Para terminar con la configuración de pusher modifica el application.html.erb, al final debe quedar algo así:

<script src="https://js.pusher.com/4.3/pusher.min.js"></script>
<script>

    // Enable pusher logging - don't include this in production
    Pusher.logToConsole = true;

    var pusher = new Pusher(ENV['PUSHER_KEY'], {
      cluster: 'us2',
      encrypted: true
    });

    var channel = pusher.subscribe('chat-group');
    channel.bind('public-message', function(data) {
      $('.messages-list').append(`<li class='mt-2'><h5><span class='badge badge-light text-left'><p class='font-weight-light'>${data.user}</p><p class='font-weight-normal'>${data.message}</p></span></h5></li>`);
      });
</script>

Después crearemos la estructura básica para almacenar los mensajes. Desde tu terminal puedes ejecutar:

bundle exec rails g model message user_uid:string username:string message:string

También necesitaremos de un controlador para comunicarnos desde el navegador:

bundle exec rails g controller group_chat index create

Modificamos nuestro archivo de routes.rb con lo siguiente:

root 'group_chat#index'
post 'group_chat', to: 'group_chat#create'

Con esto podemos agregar la vista para comenzar a interactuar. Para esto vas a tener que agregar bootstrap al proyecto. Ya que lo tengas integrado, edita el archivo app/views/group_chat/index.html.erb con lo siguiente:

<div class="container">
 <div class="row">
   <div class="col-10">
     <div class="row">
       <div class="col-12 mt-4">
         <div class="card">
           <div class="card-body">
             <ul class="list-unstyled messages-list">
               <% @messages.each do |message| %>
                 <li class="mt-2">
                   <h5>
                     <span class="badge badge-light text-left">
                       <p class="font-weight-light"><%= message.username %></p>
                       <p class="font-weight-normal"><%= message.message %></p>
                     </span>
                   </h5>
                 </li>
               <% end %>
             </ul>
           </div>
         </div>
       </div>
     </div>
     <div class="row">
       <div class="col-12">
        <form id="groupChatForm"  class="mt-2">
         <div class="form-group">
           <label for="exampleFormControlTextarea1">Message</label>
           <textarea class="form-control" id="message" data-username="dummyname" rows="3"></textarea>
         </div>
         <button type="button" id="sendMessage" class="btn btn-primary">Send</button>
        </form>
       </div>
     </div>
   </div>
 </div>
</div>

No te preocupes si no entiendes el código, es solamente una interfaz de un chat como los conocemos, tiene 2 secciones, la principal donde ves los mensajes y un input hasta abajo que es donde escribimos los mensajes y los enviamos.

Teniendo nuestra vista, ahora nos hace falta conectarla a nuestro controlador, edita app/controllers/group_chat_controller.rb con lo siguiente:

class GroupChatController < ApplicationController
 skip_before_action :verify_authenticity_token

 def index
   @messages = Message.order(created_at: :asc)
 end

 def create
   message = Message.create(user_uid: session.id, username: username, message: params[:message])
   publish(message)
   render json: message, status: :created
 end

 private

 def username
   session[:username] ||= "guest#{rand(1000)}"
 end

 def permited_params
   params.require(:group_chat).permit(:message)
 end

 def publish(message)
   connection = Bunny.new
   connection.start
   channel = connection.create_channel
   exchange = channel.fanout('logs')
   exchange.publish(message.id.to_s)
   connection.close
 end
end

Listo, o tal vez no, ya tenemos el frontend y el backend, aun así nos estamos olvidando de la conexión entre ellos que se encargue de recibir los mensajes y de enviarlos asíncronamente, porque un chat donde tienes que recargar la página para ver los nuevos mensajes no es de mucha ayuda.

Para esto, vamos a crear un archivo de javascript como app/assets/javascripts/group_chat/form.js y ponerle lo siguiente:

(() => {

 const chatForm = document.getElementById('groupChatForm');
 const message = document.getElementById('message');
 const sendMessage = document.getElementById('sendMessage');

 const sendRequest = () => {
   const username = message.dataset.username;
   const data = {username: username, message: message.value};
   $.ajax({
     url: "group_chat/",
     type: 'POST',
     data: JSON.stringify(data),
     contentType: 'application/json',
   }).done(function(resp) {
    message.value = ''
   }).fail(function(err) {
    console.log("Error", err);
   });
 };

 if(sendMessage){
   sendMessage.addEventListener('click', ev => {
     sendRequest();
   });
 };
})();

Recuerda agregar este archivo en los assets de rails o config/initializer/assets.rb y de requerirlo en nuestra vista de index, algo como esto sería suficiente:

<%= javascript_include_tag 'group_chat/form' %>

Muy bien, muy bien, ya casi estamos listos, hasta ahorita solo hemos publicado mensajes con el método publish del controlador, pero no estaría completo sin el código que escuche por ellos.

Hay varias formas de hacerlo entre RabbitMQ y Rails, esta que voy a mostrar es solo una de ellas. Lo que vamos hacer es crear un ejecutable en rails, dentro de la carpeta bin, llamémosle bin/consumer que se encargue de conectarse a RabbitMQ, y avisarnos si hay algun evento activo para después notificarle al frontend sobre el, algo como:

#!/usr/bin/env ruby

require_relative '../config/environment'

$stdout.sync = true
running = true
Signal.trap(:TERM) { running = false }

# while running
 connection = Bunny.new
 connection.start
 channel = connection.create_channel
 exchange = channel.fanout('logs')
 queue = channel.queue('', exclusive: true)


 def send_message(id)
   message = Message.find(id)
   Pusher.trigger('chat-group', 'public-message', {
     id:  message.id,
     user: message.username,
     message: message.message
   })
 end

 queue.bind(exchange)
 begin
   queue.subscribe(block: true) do |_delivery_info, _properties, id|
     send_message(id)
   end
 rescue Interrupt => _
   puts 'Error'
   channel.close
   connection.close
 end

Listo, ahora llegó el momento de probar todo junto, vamos a iniciar nuestro servidor de rails y nuestro ejecutable de bin/consumer (nota: CTRL+C te ayudara a cerrar el consumer cuando lo estés ejecutando), ya que estén inicializados podemos ir a la vista del chat para mandar mensajes, intenta desde varios navegadores como si fueran diferentes usuarios y ten una conversación en tiempo real gracias a Pusher, Rails y RabbitMQ.

Notas Finales

Para mi ha sido una buena experiencia el trabajar con RabbitMQ, resolver problemas con el, y darme cuenta de sus beneficios y probablemente costos de usarlo. Sin embargo, RabbitMQ es un sistema seguro cuando se requieren realizar procesos ya que es posible que un mensaje quede guardado en la queue hasta que el cliente confirme que ya fue consumido, también si por alguna razón hay fallas en el sevidor el queue mantiene los mensajes.

En nuestro ejemplo teníamos RabbitMQ corriendo en la misma aplicación, sin embargo, no es algo tan comun, por lo general las aplicaciones que hacen uso de RabbitMQ están mas distribuidas y requieren comunicarse entre diferentes aplicaciones o arquitecturas, no es necesario que esten en el mismo lenguaje de programacion ni en el mismo servidor. Además es muy útil cuando se requiere correr procesos pesados evitando afectar al servidor web con peticiones que tomen mucho tiempo en procesarse, incrementando el performance de la aplicación y que el usuario reciba respuestas mas rápidas.

Espero hayas podido disfrutar de esta aplicación como yo lo hice, si tienes dudas compartelas, también te invito a revisar la version terminada en mi repositorio: Chat app




comments powered by Disqus

Siguenos

Boletí de noticias