In this article we will demonstrate how to build a WebSockets driven application using Rails 5’s ActionCable. By using RethinkDB's active changefeeds feature we can eliminate the need for a separate event broadcasting service like Redis, resulting in elegant and succinct code.
The application that we will be building is a collaborative spreadsheet application much like Google Sheets. It will broadcast to each user the selected cells in different colors, the data in the cells and will guard against multiple users editing the same cell.
First we'll give a short introduction into how ActionCable and RethinkDB work, then we will move to the implementation of a small but powerful application. You can check out the source code of the end result by going here.
Setting up a basic ActionCable channel
ActionCable is a new Rails 5 feature that allows developers to add WebSocket services into their applications in a simple way. GoRails has an excellent full tutorial on ActionCable located here. For this article, just the bare essentials are needed.
Create new base project by running rails new <your_project_name>
Add a route to the ActionCable server in your config/routes.rb
:
Rails.application.routes.draw do
mount ActionCable.server => '/cable'
end
Generate an ActionCable channel:
rails g channel active_users
Then, to show a page where we can make the WebSocket connection we generate a simple controller:
rails g controller spreadsheet index
Finally we set the spreadsheet index as the root route by adding the following line to the end of the draw block in config/routes.rb
:
root 'spreadsheet#index'
This sequence of steps has provided us with a fully functioning WebSockets server. We can test it by running passenger start
or rails s
, then pointing a browser to http://localhost:3000
, opening the inspector and inspecting the /cable
WebSocket connection. You should see the initial subscription to the active_users
channel pass by as well as a steady stream of heartbeat frames.
Introducing RethinkDB to the application
RethinkDB is a document oriented data store much like MongoDB, but with some key advantages. It has a query language that maps very nicely to dynamic languages like Ruby, Node.JS and Python, it also has automatic sharding and replication features which makes it a relatively safe choice for applications that might scale to large numbers of users. However, the differentiating feature we are interested in for this application is changefeeds.
Changefeed queries start out like any regular query, but instead of the query going over the collection once and then returning its results the query remains alive. While the query is alive it returns any new results as they are entered into the database.
We will show how we can use changefeeds to power a reactive architecture for our web application in the next sections.
As a prerequisite, first install RethinkDB by following one of the installation guides for your operating system. Then open a new terminal and run rethinkdb
to start the database system. You can visit http://localhost:8080 to browse around its administration interface.
Adding RethinkDB models to the application
The easiest way of adding RethinkDB models to your Ruby on Rails application is by using the NoBrainer gem. It will take care of connections, and wrap your documents in objects with relations and attributes just like ActiveRecord does.
To use it simply add it to your Gemfile
:
gem 'nobrainer'
gem 'nobrainer_streams'
Then run bundle
and rails g nobrainer:install
to have your application use NoBrainer instead of ActiveRecord.
NoBrainer creates databases and tables for you automatically, so we can skip the migration writing step and move on directly to the creation of a model. Simply add the following code to app/models/user.rb
:
class User
include NoBrainer::Document
field :selected_cell
end
Now we can use it in our channel by populating the lifecycle hooks and adding an action. Edit the app/channels/active_users.rb
file to look like so:
class ActiveUsersChannel < ApplicationCable::Channel
include NoBrainer::Streams
def subscribed
@user = User.create
stream_from User.all, include_initial: true
end
def unsubscribed
@user.destroy
end
end
The subscribed
lifecycle callback now creates a new user every time a new channel is established. The stream_from
invocation runs a RethinkDB query that is streamed directly to the WebSocket client. It streams all existing users, and then continues to stream users whenever they are created or destroyed.
The unsubscribed
callback simply destroys the user associated with the channel. Note that this is not a very robust way of managing sessions, as the session might end without the unsubscribed
hook being called in some scenarios. This results in leaking abandoned sessions in the database, but it will do for this basic demonstration.
Rendering the data on the client side
For showing the results of the query we will build a simple view by adding the following HTML to app/views/spreadsheet/index.html.erb
:
<section id="active_users">
<h2>Active users</h2>
<ul id="active_users_list">
</ul>
</section>
To populate this view we first edit the receive
method of the client side channel implementation in app/assets/javascripts/channels/active_users.coffee
to update the client side model whenever new data arrives:
App.active_users = App.cable.subscriptions.create "ActiveUsersChannel",
received: (data) ->
if data.old_val && !data.new_val
App.spreadsheet.remove_user(data.old_val)
else if data.new_val
App.spreadsheet.new_user(data.new_val)
Then we update the view with some simple jQuery commands in app/assets/javascripts/spreadsheet.coffee
:
App.spreadsheet =
active_users: {}
new_user: (user) ->
@active_users[user.id] = user
@render_active_users()
remove_user: (user) ->
delete @active_users[user.id]
@render_active_users()
render_active_users: () ->
$('#active_users_list').html(
("<li>#{user.id}</li>" for id,user of @active_users).join("")
)
At this point you can open a few sessions by navigating to localhost:3000
in multiple browser tabs and see how the user list immediately updates.
Now that we have gone through the basics of setting up a basic ActionCable channel from the backend to the frontend, we will move on to make the application a little more complex and implement a multi-user spreadsheet.
Implementing a multi-user spreadsheet
From this point on we're going to show only the essence of the changes needed to make each feature work as the slightly more complex features tend to require additional logic that distracts from the core idea. If you would like to follow along and implement every step, have a look at this github repo that has every step as a single git commit.
To set the scene for our spreadsheet we add a quick and dirty spreadsheet view using jQuery and HandsOnTable. A more forward looking developer might refactor this to use React or Polymer.
We add the spreadsheet element to our HTML in app/views/spreadsheet/index.html.erb
:
<% content_for(:head) do %>
<%= javascript_include_tag "https://cdnjs.cloudflare.com/ajax/libs/handsontable/0.26.1/handsontable.full.js" %>
<%= stylesheet_link_tag "https://cdnjs.cloudflare.com/ajax/libs/handsontable/0.26.1/handsontable.full.css" %>
<% end %>
<!-- ... -->
<section id="spreadsheet">
</section>
Then we add a setup function to app/assets/javascripts/spreadsheet.coffee
:
App.spreadsheet =
# ...
setup: () ->
container = document.getElementById('spreadsheet')
@hot = new Handsontable(container,
minSpareCols: 1
minSpareRows: 1
rowHeaders: true
colHeaders: true
contextMenu: true
)
$ -> App.spreadsheet.setup()
Streaming field selections as colorful cells
A cool feature of the multi-user spreadsheet is that you can see what cells other users have selected. To implement this feature we add an action that stores a user's cell selection to our channel in app/channels/active_users.rb
:
class ActiveUsersChannel < ApplicationCable::Channel
# ...
def select_cells(message)
@user.update! selected_cells: message['selected_cells']
end
end
Then on the Javascript side we add a function that invokes that action over the WebSocket connection in app/assets/javascripts/channels/active_users.coffee
:
App.active_users = App.cable.subscriptions.create "ActiveUsersChannel",
# ...
select_cells: (cells) ->
@perform('select_cells', selected_cells: cells)
That function in turns gets invoked every time a selection is made, or a selection is discarded, on the spreadsheet. Since we are already subscribed to the users these changes will immediately be streamed back to the channel, so we also add a render_selected_cells
function that adds the user-<num>
CSS class to selected cells. Both in app/assets/javascripts/spreadsheet.coffee
:
App.spreadsheet =
setup: () ->
# ...
@hot = new Handsontable(container,
afterSelection: () => @select_cells(arguments)
afterDeselect: () => @deselect_cells()
# ...
select_cells: (cells) ->
App.active_users.select_cells(r: cells[0], c: cells[1], r2: cells[2], c2: cells[3])
deselect_cells: () ->
App.active_users.select_cells(null)
render_selected_cells: () ->
for cells in @selected_cells
cell = @hot.getCell(cells.r, cells.c)
if cell.classList.contains("current")
cell.classList = "current"
else
cell.classList = ""
@selected_cells = []
for id, user of @active_users
if id != @current_user.id && (cells = user.selected_cells)
@selected_cells.push(cells)
cell = @hot.getCell(cells.r, cells.c)
cell.classList.add('user-' + user.num)
Now visualizing the selected styles is simply a matter of defining the color scale in
/app/assets/stylesheets/spreadsheet.scss
:
@mixin colored-border($color) {
box-shadow:inset 0px 0px 0px 2px $color;
}
.user-1 { @include colored-border(#33a02c);}
.user-2 { @include colored-border(#e31a1c);}
.user-3 { @include colored-border(#ff7f00);}
.user-4 { @include colored-border(#6a3d9a);}
.user-5 { @include colored-border(#b15928);}
.user-6 { @include colored-border(#a6cee3);}
.user-7 { @include colored-border(#b2df8a);}
.user-8 { @include colored-border(#fb9a99);}
.user-9 { @include colored-border(#fdbf6f);}
.user-10 { @include colored-border(#cab2d6);}
.user-11 { @include colored-border(#ffff99);}
.user-12 { @include colored-border(#1f78b4);}
Go ahead and open up a few tabs just to have fun looking at the colored cells moving around.
Transmitting the field values
This part will be really simple, yet it is the core of the application. First we introduce a model for the spreadsheet cells in app/models/spreadsheet_cell.rb
:
class SpreadsheetCell
include NoBrainer::Document
field :location
field :value
end
Then we generate a channel (using rails g channel spread_sheet_cells
) and populate it with a stream for the cell values and an action for updating a cell in app/channels/spread_sheet_cells_channel.rb
:
class SpreadSheetCellsChannel < ApplicationCable::Channel
include NoBrainer::Streams
def subscribed
stream_from SpreadsheetCell.all, include_initial: true
end
def set_cell_value(message)
location = message['location']
SpreadsheetCell.upsert! location: location, value: message['value']
end
end
Then we open the client side at app/assets/javascripts/channels/spread_sheet_cells.coffee
and implement the communication endpoints:
App.spread_sheet_cells = App.cable.subscriptions.create "SpreadSheetCellsChannel",
received: (data) ->
App.spreadsheet.update_cell(data.new_val)
set_cell_value: (location, value) ->
@perform('set_cell_value', location: location, value: value)
Now the only thing left to do is to implement the client side controller at app/assets/javascripts/spreadsheet.coffee
. We add an afterChanged
event to store the new value, and an update_cell
action that sets incoming data:
App.spreadsheet =
# ...
setup: () ->
# ...
@hot = new Handsontable(container,
afterChange: (changes, source) =>
if source != 'remote' && changes
for change in changes
App.spread_sheet_cells.set_cell_value(
{ r: change[0], c: change[1] },
change[3]
)
# ...
)
update_cell: (update) ->
location = update.location
value = update.value
@hot.setDataAtCell(location.r, location.c, value, 'remote')
Just a couple of lines and we end up with an application that is already quite impressive. Open it up in a few browser windows and add some data. The real time streaming aspect of WebSockets makes the app a really satisfying experience.
To give this application just a little bit of extra robustness we will add a slightly more advanced feature: edit locks.
Implementing locks to prevent concurrent edits
When a user starts typing in a cell it would be unfortunate if another user started editing that cell at the same time and one of their modifications was lost. The most straightforward solution to this problem is to lock the cell to whoever selects the cell for editing first.
We will implement edit protection locks as the last feature of this spreadsheet application. Starting at the backend again, first we add two methods to the User
class in app/models/user.rb
:
class User
include NoBrainer::Document
field :selected_cells
before_destroy :unlock_cell
def lock_cell(location)
NoBrainer.run do |r|
SpreadsheetCell.rql_table
.get(location)
.replace do |row|
r.branch(
row.eq(nil),
{ location: location, lock: id },
row.merge(
r.branch(row['lock'].eq(nil), {lock: id},{})
))
end
end
end
def unlock_cell
SpreadsheetCell.where(lock: id).update_all lock: nil
end
end
Whenever a user opens a cell for editing, before the cell allows the user to edit it will ask the server for a lock. The complex looking RethinkDB query in the lock_cell
method, in one operation: looks up the cell, checks if it is locked, and if it is not locked sets the lock to the id of the user. Since we are setting the lock in the SpreadsheetCell
document that all users are subscribed to, all users will receive any updates in lock statuses of cells.
The unlock_cell
command looks through the entire document to find locks and releases them. We also introduced a before_destroy
callback so that when the user closes their connection any locks they held are released.
On the client side we injected a monkey patch to HandsOnTable to allow us to intercept the beginEditing
and finishEditing
functions by setting acquireEditLock
and releaseEditLock
properties. They look like this:
App.spreadsheet =
# ...
setup: () ->
@selected_cells = []
@cell_lock_callback = {}
container = document.getElementById('spreadsheet')
@hot = new Handsontable(container, ..)
@hot.acquireEditLock = (editor, callback) =>
location = {r: editor.row, c: editor.col}
@cell_lock_callback[location] = callback
App.active_users.lock_cell(location)
@hot.releaseEditLock = (editor, callback) =>
location = {r: editor.row, c: editor.col}
App.active_users.unlock_cell(location)
callback()
update_cell: (update) ->
location = r: update.location[0], c: update.location[1]
value = update.value
@hot.setDataAtCell(location.r, location.c, value, 'remote')
if update.lock == @current_user.id
@cell_lock_callback[location]?()
delete @cell_lock_callback[location]
# ...
In acquireEditLock
we store the callback that enables the user to edit the cell they selected and we send the request for the lock to the active_users
channel.
In releaseEditLock
we simply send the unlock_cell
command to the active_users
channel.
Finally when in update_cell
a lock is detected for the current user, we look up if there is an associated callback and invoke it.
Bleeding edge technology
Rails 5 is a fresh release and ActionCable is the new kid on the block. A consequence of working with new technologies like this is that the ecosystem is not fully adjusted to them yet. For the purposes of this blog we implemented monkey patches to both the RethinkDB Ruby driver and the NoBrainer ORM to ensure a seamless integration. They are injected through a gem but there are PR's outstanding for both the driver and the ORM. With time these or other PR's will get merged and we can enjoy these new technologies without friction.
Conclusion
After a short introduction to both the Rails 5 WebSocket component: ActionCable, and the RethinkDB database system, this article took us through the implementation of a concurrent & collaborative spreadsheet. The spreadsheet shows selected cells, guards against simultaneous edits of single cells, and immediately persists all data.
ActionCable makes setting up data channels and making remote procedure calls easy work. RethinkDB can be made to integrate with ActionCable channels and allows us to implement communication between clients that is persisted and broadcasted without the need of an additional message broker.
We hope this article has inspired you to build exciting new applications or features driven by WebSockets.
If you liked this article please stay tuned by subscribing to our mailing list, and be sure to give it an upvote on HN or Reddit. We recently published a blog post on ActionCable located here that will soon be followed up with a part 2.