Consertando o Demo de Chat do DHH no Rails 5

PT | EN
28 de dezembro de 2015 · 💬 Participe da Discussão

Pois é, o Rails 5.0.0 Beta 1 acabou de ser lançado e a grande novidade é o Action Cable.

Basicamente é uma solução completa em cima do Rails padrão para você implementar aplicações baseadas em WebSocket (os famosos chats e notificações em tempo real) com acesso total ao que o Rails te dá (models, view templates, etc). Para apps pequenos e médios, é uma solução excelente que você pode usar no lugar de ter que partir para Node.js.

Resumindo, você controla Cable Channels que recebem mensagens enviadas por uma conexão WebSocket no cliente. O novo gerador de Channel cuida do boilerplate e você só precisa preencher os espaços em branco para definir que tipo de mensagem quer enviar do cliente, o que quer transmitir do servidor, e para quais channels seus clientes estão inscritos.

Para uma introdução mais detalhada, o próprio DHH publicou um screencast bem básico de Action Cable que você deveria assistir só para ter uma ideia do porquê de toda essa empolgação. Se você já assistiu e tem alguma experiência em programação, talvez já tenha percebido o problema que menciono no título, então pula direto para a seção “O Problema” abaixo para o TL;DR.

No final você acaba com uma base de código como a que reproduzi no meu repositório do Github até a tag “end_of_dhh”. Você vai ter um chat em tempo real (bem) básico de uma única sala para brincar com os principais componentes.

Vamos só listar os principais componentes aqui. Primeiro, você terá o servidor ActionCable montado no arquivo “routes.rb”:

# config/routes.rb
Rails.application.routes.draw do
  root to: 'rooms#show'

  # Serve websocket cable requests in-process
  mount ActionCable.server => '/cable'
end

Este é o componente principal do servidor, o channel:

# app/channels/room_channel.rb
class RoomChannel < ApplicationCable::Channel
  def subscribed
    stream_from "room_channel"
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end

  def speak(data)
    Message.create! content: data['message']
  end
end

Depois você tem o Javascript boilerplate:

# app/assets/javascripts/cable.coffee
#= require action_cable
#= require_self
#= require_tree ./channels
#
@App ||= {}
App.cable = ActionCable.createConsumer()

E os principais hooks de Websocket no lado do cliente:

# app/assets/javascripts/channels/room.coffee
App.room = App.cable.subscriptions.create "RoomChannel",
  connected: ->
    # Called when the subscription is ready for use on the server

  disconnected: ->
    # Called when the subscription has been terminated by the server

  received: (data) ->
    $('#messages').append data['message']

  speak: (message) ->
    @perform 'speak', message: message

$(document).on "keypress", "[data-behavior~=room_speaker]", (event) ->
  if event.keyCode is 13
    App.room.speak event.target.value
    event.target.value = ''
    event.preventDefault()

O view template é um HTML básico só para amarrar um formulário simples e uma div para listar as mensagens:

<!-- app/views/rooms/show.html.erb -->
<h1>Chat room</h1>

<div id="messages">
  <%= render @messages %>
</div>

<form>
  <label>Say something:</label><br>
  <input type="text" data-behavior="room_speaker">
</form>

O Problema

No “RoomChannel”, você tem o método “speak” que salva uma mensagem no banco de dados. Isso já é um sinal vermelho para uma ação de WebSocket, que deveria ter processamento curto e leve. Salvar no banco é considerado pesado, principalmente sob carga. Se isso for processado dentro do reactor loop do EventMachine, vai travar o loop e impedir que outro processamento concorrente aconteça até o banco soltar o lock.

# app/channels/room_channel.rb
class RoomChannel < ApplicationCable::Channel
  ...
  def speak(data)
    Message.create! content: data['message']
  end
end

Eu diria que tudo que entra dentro do channel deveria ser assíncrono!

Para piorar a situação, é isso que você tem dentro do próprio model “Message”:

class Message < ApplicationRecord
  after_create_commit { MessageBroadcastJob.perform_later self }
end

Um callback de model (fuja desses como da peste!!) para transmitir a mensagem recebida para os clientes Websocket inscritos como um ActiveJob, que se parece com isso:

class MessageBroadcastJob < ApplicationJob
  queue_as :default

  def perform(message)
    ActionCable.server.broadcast  'room_channel', message: render_message(message)
  end

  private

  def render_message(message)
    ApplicationController.renderer.render(partial: 'messages/message', locals: { message: message })
  end
end

Ele renderiza o trecho de HTML para mandar de volta para os clientes Websocket adicionarem ao DOM dos seus navegadores.

O DHH ainda chega a dizer “Eu queria mostrar isso porque é assim que a maioria dos apps vai acabar ficando.”

E de fato, o problema é justamente que a maioria das pessoas vai seguir esse padrão e cair nessa armadilha. Então, qual a solução então?

A Solução Correta

Só para os fins de um screencast simples, vamos fazer um conserto rápido.

Antes de tudo, se for possível, você quer que o código do seu channel bloqueie o mínimo possível. Esperar uma operação bloqueante no banco (gravação) definitivamente não é uma dessas operações. O Job está sendo subutilizado, ele deveria ser chamado direto do método “speak” do channel, assim:

# app/channels/room_channel.rb
 class RoomChannel < ApplicationCable::Channel
   ...
   def speak(data)
-    Message.create! content: data['message']
+    MessageBroadcastJob.perform_later data['message']
   end
 end

Depois, movemos a gravação no model para dentro do próprio Job:

# app/jobs/message_broadcast_job.rb
 class MessageBroadcastJob < ApplicationJob
   queue_as :default

-  def perform(message)
-    ActionCable.server.broadcast  'room_channel', message: render_message(message)
+  def perform(data)
+    message = Message.create! content: data
+    ActionCable.server.broadcast 'room_channel', message: render_message(message)
   end
   ...

E finalmente, removemos aquele callback horrível do model e deixamos ele limpo de novo:

# app/models/message.rb
class Message < ApplicationRecord
end

Isso retorna rápido, joga o processamento para um job em background e deveria suportar mais concorrência de saída. A solução anterior, do DHH, tem um gargalo embutido no método speak e vai engasgar assim que o banco virar o gargalo.

Está longe de ser uma solução perfeita ainda, mas é menos terrível para um demo bem rápido e o código fica até mais simples. Você pode conferir esse código no meu commit no Github.

Posso estar errado na conclusão de que o channel vai bloquear ou se isso é mesmo prejudicial para a concorrência. Não medi as duas soluções, é só uma intuição vinda de feridas antigas. Se você tem mais conhecimento sobre a implementação do Action Cable, deixa um comentário aí embaixo.

Aliás, tenha cuidado antes de pensar em migrar seu app de Rails 4.2 para Rails 5 já. Por causa das dependências hard coded em Faye e Eventmachine, o Rails 5 hoje exclui o Unicorn (até o Thin parece estar tendo problema para subir). Também exclui JRuby e MRI no Windows por causa do Eventmachine.

Se você quer as capacidades do Action Cable sem precisar migrar, pode usar soluções como o “Pusher.com”, ou se quer sua própria solução in-house, acompanhe minha evolução no assunto com o meu mini clone do Pusher escrito em Elixir.