Intro
Index
Rails ActionCable allows you to open a socket connection from inside rails to the client and then push info from the server to the client. HTTP was designed for the client to ask for info from the server in the form of a request and then the server would send info to the client, whereas web sockets enables the server or the client to be the initiator and send info without either knowing when it’s coming. This tutorial will cover a basic stock ticker example utilizing the Rails 5 feature. The example will display stocks with their price and the price of the stocks will update on the clients browser whenever it changes.
Rails ActionCable Tutorial
Setting up Rails 5
To get started you need to be using rails5-beta3
This process is largely up to you but here is one way of doing this with RVM:
$ rvm gemset create rails5-beta3
ruby-2.2.2 - #gemset created /home/agent-006/.rvm/gems/ruby-2.2.2@rails5-beta3
ruby-2.2.2 - #generating rails5-beta3 wrappers........
$ rvm gemset use rails5-beta3
Using ruby-2.2.2 with gemset rails5-beta3
$ gem install rails -v 5.0.0.beta3 --no-rdoc --no-ri
Fetching: rack-2.0.0.alpha.gem (100%)
Successfully installed rack-2.0.0.alpha
Fetching: concurrent-ruby-1.0.1.gem (100%)
Successfully installed concurrent-ruby-1.0.1
Fetching: sprockets-3.5.2.gem (100%)
Successfully installed sprockets-3.5.2
Fetching: thread_safe-0.3.5.gem (100%)
Successfully installed thread_safe-0.3.5
Fetching: tzinfo-1.2.2.gem (100%)
Successfully installed tzinfo-1.2.2
Fetching: i18n-0.7.0.gem (100%)
Successfully installed i18n-0.7.0
Fetching: activesupport-5.0.0.beta3.gem (100%)
Successfully installed activesupport-5.0.0.beta3
Fetching: mini_portile2-2.0.0.gem (100%)
Successfully installed mini_portile2-2.0.0
Fetching: nokogiri-1.6.7.2.gem (100%)
Building native extensions. This could take a while...
Successfully installed nokogiri-1.6.7.2
Fetching: loofah-2.0.3.gem (100%)
Successfully installed loofah-2.0.3
Fetching: rails-html-sanitizer-1.0.3.gem (100%)
Successfully installed rails-html-sanitizer-1.0.3
Fetching: rails-deprecated_sanitizer-1.0.3.gem (100%)
Successfully installed rails-deprecated_sanitizer-1.0.3
Fetching: rails-dom-testing-1.0.7.gem (100%)
Successfully installed rails-dom-testing-1.0.7
Fetching: rack-test-0.6.3.gem (100%)
Successfully installed rack-test-0.6.3
Fetching: erubis-2.7.0.gem (100%)
Successfully installed erubis-2.7.0
Fetching: builder-3.2.2.gem (100%)
Successfully installed builder-3.2.2
Fetching: actionview-5.0.0.beta3.gem (100%)
Successfully installed actionview-5.0.0.beta3
Fetching: actionpack-5.0.0.beta3.gem (100%)
Successfully installed actionpack-5.0.0.beta3
Fetching: sprockets-rails-3.0.3.gem (100%)
Successfully installed sprockets-rails-3.0.3
Fetching: thor-0.19.1.gem (100%)
Successfully installed thor-0.19.1
Fetching: method_source-0.8.2.gem (100%)
Successfully installed method_source-0.8.2
Fetching: railties-5.0.0.beta3.gem (100%)
Successfully installed railties-5.0.0.beta3
Fetching: bundler-1.11.2.gem (100%)
Successfully installed bundler-1.11.2
Fetching: arel-7.0.0.gem (100%)
Successfully installed arel-7.0.0
Fetching: activemodel-5.0.0.beta3.gem (100%)
Successfully installed activemodel-5.0.0.beta3
Fetching: activerecord-5.0.0.beta3.gem (100%)
Successfully installed activerecord-5.0.0.beta3
Fetching: globalid-0.3.6.gem (100%)
Successfully installed globalid-0.3.6
Fetching: activejob-5.0.0.beta3.gem (100%)
Successfully installed activejob-5.0.0.beta3
Fetching: mime-types-2.99.1.gem (100%)
Successfully installed mime-types-2.99.1
Fetching: mail-2.6.3.gem (100%)
Successfully installed mail-2.6.3
Fetching: actionmailer-5.0.0.beta3.gem (100%)
Successfully installed actionmailer-5.0.0.beta3
Fetching: websocket-extensions-0.1.2.gem (100%)
Successfully installed websocket-extensions-0.1.2
Fetching: websocket-driver-0.6.3.gem (100%)
Building native extensions. This could take a while...
Successfully installed websocket-driver-0.6.3
Fetching: nio4r-1.2.1.gem (100%)
Building native extensions. This could take a while...
Successfully installed nio4r-1.2.1
Fetching: actioncable-5.0.0.beta3.gem (100%)
Successfully installed actioncable-5.0.0.beta3
Fetching: rails-5.0.0.beta3.gem (100%)
Successfully installed rails-5.0.0.beta3
36 gems installed
Note, when the terminal session is closed RVM will revert to the default gemset which will have whatever Rails version you had before. Use $ rvm gemset use rails5-beta3
to use Rails 5 again.
To make sure Rails 5 is properly configured run the command: $ rails -v
It should output: Rails 5.0.0.beta3
The First Step
Now the environment is setup and ready to create a Rails 5 application.
$ rails new stock-ticker
create
create README.md
create Rakefile
create config.ru
create .gitignore
create Gemfile
create app
create app/assets/config/manifest.js
create app/assets/javascripts/application.js
create app/assets/javascripts/cable.coffee
create app/assets/stylesheets/application.css
create app/channels/application_cable/channel.rb
create app/channels/application_cable/connection.rb
create app/controllers/application_controller.rb
create app/helpers/application_helper.rb
create app/jobs/application_job.rb
create app/mailers/application_mailer.rb
create app/models/application_record.rb
create app/views/layouts/application.html.erb
create app/views/layouts/mailer.html.erb
create app/views/layouts/mailer.text.erb
create app/assets/images/.keep
create app/assets/javascripts/channels
create app/assets/javascripts/channels/.keep
create app/controllers/concerns/.keep
create app/models/concerns/.keep
create bin
create bin/bundle
create bin/rails
create bin/rake
create bin/setup
create bin/update
create config
create config/routes.rb
create config/application.rb
create config/environment.rb
create config/secrets.yml
create config/cable.yml
create config/puma.rb
create config/environments
create config/environments/development.rb
create config/environments/production.rb
create config/environments/test.rb
create config/initializers
create config/initializers/active_record_belongs_to_required_by_default.rb
create config/initializers/application_controller_renderer.rb
create config/initializers/assets.rb
create config/initializers/backtrace_silencers.rb
create config/initializers/callback_terminator.rb
create config/initializers/cookies_serializer.rb
create config/initializers/cors.rb
create config/initializers/filter_parameter_logging.rb
create config/initializers/inflections.rb
create config/initializers/mime_types.rb
create config/initializers/per_form_csrf_tokens.rb
create config/initializers/request_forgery_protection.rb
create config/initializers/session_store.rb
create config/initializers/wrap_parameters.rb
create config/locales
create config/locales/en.yml
create config/boot.rb
create config/database.yml
create db
create db/seeds.rb
create lib
create lib/tasks
create lib/tasks/.keep
create lib/assets
create lib/assets/.keep
create log
create log/.keep
create public
create public/404.html
create public/422.html
create public/500.html
create public/apple-touch-icon-precomposed.png
create public/apple-touch-icon.png
create public/favicon.ico
create public/robots.txt
create test/fixtures
create test/fixtures/.keep
create test/fixtures/files
create test/fixtures/files/.keep
create test/controllers
create test/controllers/.keep
create test/mailers
create test/mailers/.keep
create test/models
create test/models/.keep
create test/helpers
create test/helpers/.keep
create test/integration
create test/integration/.keep
create test/test_helper.rb
create tmp
create tmp/.keep
create tmp/cache
create tmp/cache/assets
create vendor/assets/javascripts
create vendor/assets/javascripts/.keep
create vendor/assets/stylesheets
create vendor/assets/stylesheets/.keep
remove config/initializers/cors.rb
run bundle install
Fetching gem metadata from https://rubygems.org/...........
Fetching version metadata from https://rubygems.org/...
Fetching dependency metadata from https://rubygems.org/..
Resolving dependencies.......
Installing rake 10.5.0
Using concurrent-ruby 1.0.1
Using i18n 0.7.0
Installing minitest 5.8.4
Using thread_safe 0.3.5
Using builder 3.2.2
Using erubis 2.7.0
Using mini_portile2 2.0.0
Installing json 1.8.3 with native extensions
Using nio4r 1.2.1
Using websocket-extensions 0.1.2
Using mime-types 2.99.1
Using arel 7.0.0
Using bundler 1.11.2
Installing byebug 8.2.2 with native extensions
Installing coffee-script-source 1.10.0
Installing execjs 2.6.0
Using method_source 0.8.2
Using thor 0.19.1
Installing debug_inspector 0.0.2 with native extensions
Installing ffi 1.9.10 with native extensions
Installing multi_json 1.11.2
Installing rb-fsevent 0.9.7
Installing puma 3.0.2 with native extensions
Installing sass 3.4.21
Installing tilt 2.0.2
Installing spring 1.6.4
Installing sqlite3 1.3.11 with native extensions
Installing turbolinks-source 5.0.0.beta2
Using tzinfo 1.2.2
Using nokogiri 1.6.7.2
Using rack 2.0.0.alpha
Using websocket-driver 0.6.3
Using mail 2.6.3
Installing coffee-script 2.4.1
Installing uglifier 2.7.2
Installing rb-inotify 0.9.7
Installing turbolinks 5.0.0.beta2
Using activesupport 5.0.0.beta3
Using loofah 2.0.3
Using rack-test 0.6.3
Using sprockets 3.5.2
Installing listen 3.0.6
Using rails-deprecated_sanitizer 1.0.3
Using globalid 0.3.6
Using activemodel 5.0.0.beta3
Installing jbuilder 2.4.1
Using rails-html-sanitizer 1.0.3
Installing spring-watcher-listen 2.0.0
Using rails-dom-testing 1.0.7
Using activejob 5.0.0.beta3
Using activerecord 5.0.0.beta3
Using actionview 5.0.0.beta3
Using actionpack 5.0.0.beta3
Using actioncable 5.0.0.beta3
Using actionmailer 5.0.0.beta3
Using railties 5.0.0.beta3
Using sprockets-rails 3.0.3
Installing coffee-rails 4.1.1
Installing jquery-rails 4.1.0
Installing web-console 3.1.1
Using rails 5.0.0.beta3
Installing sass-rails 5.0.4
Bundle complete! 15 Gemfile dependencies, 63 gems now installed.
Use `bundle show [gemname]` to see where a bundled gem is installed.
run bundle exec spring binstub --all
* bin/rake: spring inserted
* bin/rails: spring inserted
And navigate to the folder
$ cd stock-ticker
Preparing the models, controllers and views
The stocks need to have a basic setup. To do this a scaffold for the stocks is made. The stocks will have a symbol and a price.
$ rails generate scaffold stock symbol:string price:decimal
Running via Spring preloader in process 25276
invoke active_record
create db/migrate/(timestamp)_create_stocks.rb
create app/models/stock.rb
invoke test_unit
create test/models/stock_test.rb
create test/fixtures/stocks.yml
invoke resource_route
route resources :stocks
invoke scaffold_controller
create app/controllers/stocks_controller.rb
invoke erb
create app/views/stocks
create app/views/stocks/index.html.erb
create app/views/stocks/edit.html.erb
create app/views/stocks/show.html.erb
create app/views/stocks/new.html.erb
create app/views/stocks/_form.html.erb
invoke test_unit
create test/controllers/stocks_controller_test.rb
invoke helper
create app/helpers/stocks_helper.rb
invoke test_unit
invoke jbuilder
create app/views/stocks/index.json.jbuilder
create app/views/stocks/show.json.jbuilder
invoke assets
invoke coffee
create app/assets/javascripts/stocks.coffee
invoke scss
create app/assets/stylesheets/stocks.scss
invoke scss
create app/assets/stylesheets/scaffolds.scss
And as a matter of completeness the scale and precision can be set for the price. This step isn’t necessary and can be skipped.
# db/migrate/(timestamp)_create_stocks.rb
class CreateStocks < ActiveRecord::Migration[5.0]
def change
create_table :stocks do |t|
t.string :symbol
t.decimal :price, precision: 8, scale: 2
t.timestamps
end
end
end
And then migrate.
$ rake db:migrate
== (timestamp) CreateStocks: migrating =====================================
-- create_table(:stocks)
-> 0.0034s
== (timestamp) CreateStocks: migrated (0.0036s) ============================
Enabling ActionCable
In order to start using ActionCable there are two things that need to be done. First, 2 lines of CoffeeScript have to be uncommented. These lines make the ‘App’ and ‘App.cable’ object available in other CoffeeScript files.
# app/assets/javascripts/cable.coffee
# Action Cable provides the framework to deal with WebSockets in Rails.
# You can generate new channels where WebSocket features live using the rails generate channel command.
#
# Turn on the cable connection by removing the comments after the require statements (and ensure it's also on in config/routes.rb).
#
#= require action_cable
#= require_self
#= require_tree ./channels
#
@App ||= {} # <-- these lines
App.cable = ActionCable.createConsumer() # <-- these lines
Then the cable route has to be uncommented.
# config/routes.rb
Rails.application.routes.draw do
resources :stocks
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
# Serve websocket cable requests in-process
mount ActionCable.server => '/cable' # <-- this line
end
The ActionCable Portion
Now to generate a channel for the stocks.
$ rails generate channel stocks follow unfollow_all
Running via Spring preloader in process 25935
create app/channels/stocks_channel.rb
create app/assets/javascripts/channels/stocks.coffee
Channel generation accepts the channel’s name and then a list of methods. In this case the channel name is stocks and it has a follow method and a unfollow_all method.
Now that there is a channel for the stocks there has to be a stream to distribute data as well. For now all consumers will be listening to the stream “stock_stream:all_stocks” and all data will be transmitted over this stream. Streams are an ephemeral thing that can appear and disappear when a user starts listening or all users stop listening to it. This means that there is no need to predefine streams. Another thing is the appearance of the word “consumer”, this word means “WebSocket client” in the ActionCable lexicon. The code below sets each consumer to listen to “stock_stream:all_stocks” as soon as they connect.
# app/channels/stocks_channel.rb
# Be sure to restart your server when you modify this file. Action Cable runs in a loop that does not support auto reloading.
class StocksChannel < ApplicationCable::Channel
def subscribed
stream_from "stock_stream:all_stocks" # Added default stream
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
def follow
end
def unfollow_all
end
end
The broadcast method, shown below, broadcasts data to every consumer listening to a stream.
ActionCable.server.broadcast(stream, payload)
To make the client update the stock price when it changes there needs to be a broadcast. This can be achieved by creating a hook for database commits in the stock model to broadcast whenever the stock is updated.
# app/models/stock.rb
class Stock < ApplicationRecord
validates_format_of :symbol, with: /\A[A-Za-z0-9]+\Z/, message: "can only contain letters and numbers"
validates :symbol, uniqueness: true
after_commit :broadcast, on: :update
def broadcast
ActionCable.server.broadcast( "stock_stream:all_stocks", { symbol: symbol, price: price.to_f } )
end
end
A few validations are also added here.
With all clients listening to the “stock_stream:all_stocks” stream every consumer will receive the broadcast.
When channels are generated a parallel javascript file is made.
# app/assets/javascripts/channels/stocks.coffee
App.stocks = App.cable.subscriptions.create "StocksChannel",
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) ->
# Called when there's incoming data on the websocket for this channel
console.log(data) # Log all incoming data
follow: ->
@perform 'follow'
unfollow_all: ->
@perform 'unfollow_all'
This file defines a class (App.stocks) with five functions. The comments describe the first three nicely and the last two were specified in the generation.
For now all that needs to be done is console logging all received data to see that the stream is working.
Now try it! And if you’ve been running the server be sure to restart it.
Create a stock in one tab and have the web console open in that same tab, then in another tab edit the stock. Firefox logged this in the the first tab.
Object { symbol: "LOP", price: 3.43 }
In this case a stock with a symbol of “LOP” was created and then it’s price was edited to 3.43
Using the ActionCable Portion
Now that the information part has been worked out the data has to be used to update the stocks. The first step here would be making the entry’s price findable by javascript.
<!-- app/views/stocks/index.html.erb -->
<p id="notice"><%= notice %></p>
<h1>Stocks</h1>
<table>
<thead>
<tr>
<th>Symbol</th>
<th>Price</th>
<th colspan="3"></th>
</tr>
</thead>
<tbody>
<% @stocks.each do |stock| %>
<tr id="<%= stock.symbol %>"> <!-- Added an id so javascript can find the row of a specific stock -->
<td><%= stock.symbol %></td>
<td id="price"><%= stock.price %></td> <!-- Added an id so javascript can find the price column of a stock -->
<td><%= link_to 'Show', stock %></td>
<td><%= link_to 'Edit', edit_stock_path(stock) %></td>
<td><%= link_to 'Destroy', stock, method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>
<% end %>
</tbody>
</table>
<br>
<%= link_to 'New Stock', new_stock_path %>
In the received function of the channel the price is updated.
# app/assets/javascripts/channels/stocks.coffee
received: (data) ->
# Called when there's incoming data on the websocket for this channel
console.log(data) # This will be left alone for debug purposes
$("#" + data.symbol + " #price").text(data.price)
This is usable and you can demo it now. Again, remember to restart the server.
Refining the ActionCable Portion
This is all well and good so far but there is an issue here.
No matter where the user is on the site they are listening to all the stocks. Whether they are looking at a single stock or even none at all. To fix this the consumer needs to be made to listen to a specific stream.
To tackle this problem the capabilities of streams has to be used. A ‘stream-id’ data attribute will be made and placed in the body of the page.
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title>StockTicker</title>
<%= csrf_meta_tags %>
<%= action_cable_meta_tag %>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %>
<%= javascript_include_tag 'application', 'data-turbolinks-track' => true %>
</head>
<body<% if content_for? :stock_id %> data-stream-id="<%= yield( :stock_id ) %>"<% end %>> <!-- Add data-stream-id attribute if :stock_id is present -->
<%= yield %>
</body>
</html>
And the views will supply the stream-id
<!-- app/views/stocks/index.html.erb -->
<% provide :stock_id, "all_stocks" %>
...
<!-- app/views/stocks/show.html.erb -->
<% provide :stock_id, @stock.symbol %>
<p id="notice"><%= notice %></p>
<div id="<%= @stock.symbol %>"> <!-- Wrap in container with id set to the stock symbol -->
<p>
<strong>Symbol:</strong>
<%= @stock.symbol %>
</p>
<p>
<strong>Price:</strong>
<span id="price"><%= @stock.price %></span> <!-- Set element that contains the price's id to 'price'-->
</p>
<div>
<%= link_to 'Edit', edit_stock_path(@stock) %> |
<%= link_to 'Back', stocks_path %>
In both of these edits a call to ‘provide’ sets the ‘stream-id’. In the second edit, in the same way that the entry’s price was made findable by javascript in the index view, the show view was formatted to behave similarly.
Then javascript can access the stream-id attribute. Finish the follow function that was generated and have it be called after the user connects and for every page change.
# app/assets/javascripts/channels/stocks.coffee
App.stocks = App.cable.subscriptions.create "StocksChannel",
connected: ->
# Called when the subscription is ready for use on the server
unless @callbackSet # set a callback to listen to the new stream after every page change.
$(document).on 'turbolinks:load', -> App.stocks.follow()
@callbackSet = true
@follow() # Call follow the initial time.
disconnected: ->
# Called when the subscription has been terminated by the server
received: (data) ->
# Called when there's incoming data on the websocket for this channel
console.log(data) # this will be left alone for debug purposes
$("#" + data.symbol + " #price").text(data.price)
follow: ->
@unfollow_all() # Stop listening to all streams
if $("body").attr("data-stream-id") # Listen to next stream if there is one.
@perform 'follow', stock: $("body").data("stream-id")
unfollow_all: ->
@perform 'unfollow_all'
When the javascript function ‘follow’ is called it calls the serverside method ‘unfollow_all’. ‘unfollow_all’ calls ‘stop_all_streams’ which stops all streams to the user. It then calls ‘follow’ serverside and passes it a variable called ‘stock’. Methods on the rails side of things only get called with one variable and in this case it is named ‘data’. ‘data’ contains a hash of all the variables passed in from javascript. The variable passed through javascript is ‘stock’ and it can be accessed in the method through data[‘stock’]
# app/channels/stocks_channel.rb
class StocksChannel < ApplicationCable::Channel
def subscribed # Default stream removed
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
def follow( data ) # Added data variable
stream_from "stock_stream:#{data['stock']}" # Added specific stock stream
end
def unfollow_all
stop_all_streams # Added call to 'stop_all_streams'
end
end
Adding another broadcast that broadcasts to the single stock stream finishes off that problem.
# app/models/stock.rb
class Stock < ApplicationRecord
validates_format_of :symbol, with: /\A[A-Za-z0-9]+\Z/, message: "can only contain letters and numbers"
validates :symbol, uniqueness: true
after_commit :broadcast, on: :update
def broadcast
ActionCable.server.broadcast( "stock_stream:#{symbol}", { symbol: symbol, price: price.to_f } ) # Added specific stock broadcast
ActionCable.server.broadcast( "stock_stream:all_stocks", { symbol: symbol, price: price.to_f } )
end
end
And there it is, finished. Remember to restart the server…
To see the last change create multiple stocks and view one.
When you edit that one in another tab the consumer will receive the change but when you change the other stock one no data will be broadcast to the consumer.
Diagrams
Here are a few diagrams to explain how this ActionCable example works.
Copyright Notices and Such
All text and images are copyrighted © 2016 Daniel Machen
All source code is available under the MIT License.
Copyright (c) 2016 Daniel Machen
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.