Let's just list the main components here. First, you will have the ActionCable server mounted in the "routes.rb" file:
1 2 3 4 5 6 7 |
# config/routes.rb Rails.application.routes.draw do root to: 'rooms#show' # Serve websocket cable requests in-process mount ActionCable.server => '/cable' end |
This is the main server component, the channel:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# 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 |
Then you have the boilerplace Javascript:
1 2 3 4 5 6 7 |
# app/assets/javascripts/cable.coffee #= require action_cable #= require_self #= require_tree ./channels # @App ||= {} App.cable = ActionCable.createConsumer() |
And the main client-side Websocket hooks:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
# 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() |
The view template is a bare bone HTML just to hook a simple form and div to list the messages:
1 2 3 4 5 6 7 8 9 10 11 |
<!-- 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> |
The Problem
In the "RoomChannel", you have the "speak" method that saves a message to the database. This is already a red flag for a WebSocket action that is supposed to have very short lived, light processing. Saving to the database is to be considered heavyweight, specially under load. If this is processed inside EventMachine's reactor loop, it will block the loop and avoid other concurrent processing to take place until the database releases the lock.
1 2 3 4 5 6 7 |
# app/channels/room_channel.rb class RoomChannel < ApplicationCable::Channel ... def speak(data) Message.create! content: data['message'] end end |
I would say that anything that goes inside the channel should be asynchronous!
To add harm to injury, this is what you have in the "Message" model itself:
1 2 3 |
class Message < ApplicationRecord after_create_commit { MessageBroadcastJob.perform_later self } end |
A model callback (avoid those as the plague!!) to broadcast the received messsage to the subscribed Websocket clients as an ActiveJob that looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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 |
It renders the HTML snippet to send back for the Websocket clients to append to their browser DOMs.
DHH even goes on to say "I'd like to show it because this is how most apps will end up."
Indeed, the problem is that most people will just follow this pattern and it's a big trap. So, what's the solution instead?
The Proper Solution
For just the purposes of a simple screencast, let's make a quick fix.
First of all, if at all possible you want your channel code to block as little as possible. Waiting for a blocking operation in the database (writing) is definitely not one of them. The Job is underused, it should be called straight from the channel "speak" method, like this:
1 2 3 4 5 6 7 8 |
# app/channels/room_channel.rb class RoomChannel < ApplicationCable::Channel ... def speak(data) - Message.create! content: data['message'] + MessageBroadcastJob.perform_later data['message'] end end |
Then, we move the model writing to the Job itself:
1 2 3 4 5 6 7 8 9 10 11 |
# 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 ... |
And finally, we remove that horrible callback from the model and make it bare-bone again:
1 2 3 |
# app/models/message.rb class Message < ApplicationRecord end |
This returns quickly, defer processing to a background job and should sustain more concurrency out-of-the-box. The previous, DHH solution, have a built-in bottleneck in the speak method and will choke as soon as the database becomes the bottleneck.
It's by no means a perfect solution yet, but it's less terrible for a very quick demo and the code ends up being simpler as well. You can check out this code in my Github repo commit.
I may be wrong in the conclusion that the channel will block or if this is indeed harmful for the concurrency. I didn't measure both solutions, it's just a gut feeling from older wounds. If you have more insight into the implementation of Action Cable, leave a comment down below.
By the way, be careful before considering migrating your Rails 4.2 app to Rails 5 just yet. Because of the hard coded dependencies on Faye, Eventmachine, Rails 5 right now rules out Unicorn (even Thin seems to be having problem booting up). It also rules out JRuby and MRI on Windows as well because of Eventmachine.
If you want the capabilities of Action Cable without having to migrate, you can use solutions such as "Pusher.com", or if you want your own in-house solution, follow my evolution on the subject with my mini-Pusher clone written in Elixir.