Using Rails 5 ActionCable and RethinkDB to build a Reactive WebSocket App

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.