Being a tech agency, we at robusta deal with a lot of apps with different kinds of business logic. From simple e-commerce apps to complex full-featured social networks. So being able to communicate with users is a vital feature that we have to incorporate in almost every project. We were looking for a simple solution to create chat modules in Rails apps. That was when we decided to give Action Cable a shot in one of our projects.
Action Cable
Action Cable is a simple framework that introduces WebSocket support to Rails apps. It provides a server-side as well as a client-side integration with WebSocket. DHH made a quick tutorial and a preview of Action Cable on his YouTube Channel.
WebSocket
WebSocket is a two-way TCP-based protocol. It works by opening a persistent connection between the server and the client. Both sides can use the connection for data exchange.
Drawbacks
When we decided to try ActionCable and WebSocket, we have looked for the
points where WebSocket falls behind. The main concern (still a small concern though) is that WebSocket is a relatively new protocol. Thus, older browsers lack WebSocket support. Thus, not all web browsers support WebSockets.
WebSocket Vs. Other Solutions
In our research, we had to find out the differences between WebSokcet and other solutions. Here’s a quick comparison between WebSocket and other well-known solutions.
AJAX
WebSocket protocol differs from HTTP known methods (Ajax or Polling) where the client (the browser) does not need to make a request for the data it needs to fetch.
In AJAX, the client sends a request to the server and gets an instant response from the server then the connection is closed as most HTTP requests.
Polling
Server polling resembles AJAX except that the connection is kept alive for some time until it times out and gets closed. The client requests for a new connection with the server again after a defined time.
WebRTC
WebRTC is different from the previous methods in the way the data is transmitted. In WebRTC, the data is exchanged between clients (no servers involved). On the other hand, WebSocket data exchange happens between a client and a server.
A Use Case
Here we will build a simple app to demonstrate the abilities of Action Cable. The app is somewhat similar to a Twitter replica. The app will have three main features: tweeting, messaging, and showing the online presence of registered users.
Setting up the app
We start by installing the latest version of Rails. As of this time, the latest version of Rails is 5.0.0.rc2. An important note before trying to install the latest version of Rails is that it requires Ruby 2.2.2+ to run. $ gem install rails -v 5.0.0.rc2
. We then create a new app without a test framework using $ rails new TwitterCable -T --database=mysql
. Now we need to create the database $ bundle exec rake db:create
. Next step is writing code for our app.
Authenticating users
We setup devise for user authentication. We do that by adding gem 'devise'
to the Gemfile and calling $ bundle install
. Then we use Devise’s generator to install it. $ rails generate devise:install
and $ rails generate devise User
to create our user model. Last step is to migrate the database $ bundle exec rake db:migrate
The tweeting module
To keep our app as simple as possible, we’ll add only two attributes to our Tweet model $ rails g model Tweet content:string user_id:integer
. Now we open Tweet model and add the association with User model.
# app/models/tweet.rb
class Tweet < ApplicationRecord
belongs_to :user
end
Now we need to setup the controller and the views:
# app/controllers/tweets_controller.rb
def index
@tweets = Tweet.all.order("created_at DESC")
end
def create
@tweet = current_user.tweets.create! tweet_params
redirect_to tweets_path
end
private
def tweet_params
params.require(:tweet).permit(:content)
end
# config/routes.rb
resources :tweets, only: [:index, :create]
<!-- app/views/index.html.erb -->
<div class="text-center">
<h3>Recent Tweets</h3>
</div>
<br>
<% if current_user %>
<%= form_for Tweet.new do |f|%>
<%= f.text_area :content, placeholder: 'New Tweet' %>
<div class="right">
<%= f.submit 'Add Tweet', class: 'button' %>
</div>
<% end %>
<% end %>
<br>
<div class="text-center">
<div id="tweets">
<%= render @tweets %>
</div>
</div>
<!--app/views/tweets/_tweet.html.erb -->
<% cache tweet do %>
<div class="tweet">
<div class="callout">
<h5><%= tweet.user.email %></h5>
<p>
<%= tweet.content %>
</p>
</div>
</div>
<% end %>
It’s time to try the app. In your browser, open localhost:3000/tweets
. This is where we can create new tweets and each time we we will get redirected to the tweets index page.
Up until now, everything is normal as we would’ve seen in any CRUD application but things will change a bit when we start using Action Cable to handle rendering new tweets.
We can use Rails generator to create a new channel for our tweets $ rails g channel Tweet
. This creates two new files app/channels/tweet_channel.rb and app/assets/javascripts/channels/tweet.js. The first file handles the connection on the server side while the other handles the client-side connections.
We have to mount Action Cable server in our routes file so that Action Cable can listen to WebSocket requests:
# config/routes.rb
mount ActionCable.server => '/cable'
# app/channels/tweet_channel.rb
...
def subscribed
stream_from "tweet_channel"
end
...
We will use Coffeescript for its Ruby-like syntax and our convenience. Rename app/assets/javascripts/channels/tweet.js to tweet.coffee and add the following code to the file.
# app/assets/javascripts/channels/tweet.coffee
App.twitter = App.cable.subscriptions.create "TweetChannel",
received: (data) ->
$('#tweets').prepend data['tweet']
$('[data-behavior~=tweet_field]').val('')
# Called when there's incoming data on the websocket for this channel
We then modify the form by adding remote: true
to prevent Rails from redirecting after submission.
<!-- app/views/index.html.erb -->
...
<%= form_for Tweet.new, remote: true do |f|%>
<% end %>
...
And we inform the controller to broadcast the new tweet html to the TweetChannel in the create action.
# app/controllers/tweets_controller.rb
...
def create
@tweet = current_user.tweets.create! tweet_params
ActionCable.server.broadcast "twitter_channel", tweet: render_tweet(@tweet)
head :ok
end
private
...
def render_tweet tweet
render(partial: 'tweets/tweet', locals: { tweet: tweet })
end
Now we try again to create a new tweet; the tweet will be immediately appended to the DOM of the tweets index page.
The messaging module
We start now by adding our models and filling them with methods and scopes that we will need in the next steps.
$ rails g model Conversation sender_id:integer:index recipient_id:integer:index
$ rails g model Message body:text conversation_id:integer:index user_id:integer:index
Check the
conversation model and the
message model on Github.
Next we add
conversations controller
and messages controller and we set up our
routes.
We also need to add our view templates and partials for conversations and messages controllers.
Check conversation views
and message views on Github.
The trick in the messages module is that we want to stream and subscribe to message channel based on the conversation id (a conversation is between two users).
We use Rails generator to create a new channel for our messages $ rails g channel Message
. This creates two new files app/channels/message_channel.rb and app/assets/javascripts/channels/message.js. The first file handles the connection on the server-side while the other handles the client-side connections.
On the server side, we subscribe to message_channel_#{conversation_id}, so that each connection between any two users will be unique.
# app/channels/message_channel.rb
...
def subscribed
stream_from "message_channel_#{params[:conversation_id]}"
end
...
On the client side, we will use Coffeescript for its Ruby-like syntax and our convenience. Rename app/assets/javascripts/channels/message.js to message.coffee and add the following code:
# app/assets/javascripts/channels/message.coffee
$ ->
window.conversation_id = $('#conversation_id').attr('value')
App.message = App.cable.subscriptions.create { channel: "MessageChannel", conversation_id: conversation_id },
received: (data) ->
$('#messages_' + conversation_id).append data['message']
$('[data-behavior~=new_message_field]').val('')
# Called when there's incoming data on the websocket for this channel
Here we set a global variable conversation_id
whose value is obtained from the
DOM and passed in the params object to the MessageChannel on the server-side.
The received
function is called when there is data sent from the server to
the client side. This is where we append the new message to messages_conversation_id
element in the conversations show template.
Here, we find the messages_conversation_id
element which we append new messages to.
<!-- app/views/conversations/show.html.erb -->
...
<%= content_tag :div, id: "messages_#{@conversation.id}" do %>
<%= render @conversation.messages, conversation: @conversation %>
<% end %>
...
Inside the form, we add a hidden field that holds the current conversation_id
so that the client side of the message channel can send it to the
server-side channel.
Note that we add remote: true
to prevent Rails from redirecting after the form submission.
<!-- app/views/conversations/show.html.erb -->
...
<%= form_for [@conversation, Message.new], remote: true do |f|%>
<%= f.text_area :body, placeholder: 'New Nessage', 'data-behavior' => 'new_message_field' %>
<%= f.hidden_field :conversation_id, value: params[:id], id: 'conversation_id' %>
<%= f.submit 'Send', class: 'button' %>
<% end %>
The difference in messages controller is that we broadcast our newly created
message to Action Cable server using the conversation_id of that message.
# app/controllers/messages_controller.rb
...
def create
@message = @conversation.messages.create message_params
ActionCable.server.broadcast "message_channel_#{@conversation.id}", message: render_message(@message)
end
private
def render_message(message)
render(partial: 'messages/message', locals: { message: message })
end
...
The online presence module
In this part, we will show the currently logged-in users inside the conversations index page so when we click on any user, we could chat with them directly.
The different part here is that we need Redis to store our logged-in users because, in Action Cable, each page refresh is counted as a new subscription. Thus, each time a user refreshes the web page, a duplicate entry will be created for that user in the online users list.
We will setup Redis by uncommenting redis gem in the Gemfile. And then we will add an initializer file for Redis so we can use it in any part of our application.
# Gemfile
gem 'redis', '~> 3.0'
# config/initializers/redis.rb
$redis = Redis.new(:host => '0.0.0.0', :port => 6379)
We will use Redis sets to store the id of each subscribed user and to remove the ids of unsubscribed users. Then we will rerender the partial responsible for listing the logged-in users.
We start by generating a new channel $ rails g channel OnlineUsers
and adding the code responsible for storing or removing user ids from Redis set and rendering the online_users partial.
# app/channels/online_users_channel.rb
class OnlineUsersChannel > ApplicationCable::Channel
def subscribed
stream_from "online_users_channel"
$redis.sadd 'online', current_user.id
ActionCable.server.broadcast "online_users_channel", users_html: render_online_users
end
def unsubscribed
$redis.srem 'online', current_user.id
ActionCable.server.broadcast "online_users_channel", users_html: render_online_users
end
private
def render_online_users
ApplicationController.renderer.render(partial: 'users/online_users')
end
end
On the client-side part, we use jQuery to set the html() attribute of #onlineUsers element to the html received from the server-side channel that contains the newly rendered online_users partial.
# app/assets/channels/online_users.cofee
App.online_users = App.cable.subscriptions.create "OnlineUsersChannel",
received: (data) ->
$('#onlineUsers').html(data['users_html'])
We add _online_users partial under users views. Inside the partial, we loop thought our Redis set online
and fetch each user.
Online Users
In conversations index page, we render the partial inside the #onlineUsers
element.
<!-- app/views/conversations/index.html.erb -->
...
<div id="onlineUsers">
<%= render partial: 'users/online_users'%>
</div>
...
You can check the full source code on Robusta’s Github account.
Conlusion
Action Cable is an easy and simple solution to use. It introduces a fully integrated suite that works well with Ruby on Rails on both client and server sides. To use WebSockets or not is dependent on the use case.
Resources
Real-Time Rails: Implementing WebSockets in Rails 5 with Action Cable
WebSockets, caution required!
http://stackoverflow.com/questions/10028770/in-what-situations-would-ajax-long-short-polling-be-preferred-over-html5-websock