Rob Race

@rob__race

The Practical Guide to Using ActionCable

Most guides and blog posts around ActionCable focus around simple chat apps to show the typical websocket workflow. That is great, but how is ActionCable suppose to fit into your everyday SaaS-like Rails application?

The following is a mash-up of content from my upcoming book Build A SaaS App in Rails 6. The book guides you from humble beginnings through deploying an app to production. The book is now in pre sale, and you can grab a free chapter right now!

Also, the beta for my new project Pull Manager is has been released. If you’re losing track of pull requests, have old ones lingering around or would just love a dashboard that aggregates these requests over multiple services (Github, Gitlab, and Bitbucket), check it out.

First, what is ActionCable?

In case you haven’t heard about it before…

ActionCable is Rail’s built-in library with both server-side and client-side components to push data through websockets. Websockets is a computer protocol that allows data to pass over a TCP connection. In layman’s terms, it is a way to push and pull data outside of the HTTP request/response cycle.While most times data will flow from the server to the client-side, it is possible for data to flow both ways in Rails.

The process that is normally used in WebSocket and real-time is called Pub/Sub, or Publication/Subscription. You will sometimes also see this referred to as a message bus structure in regards to the actual communication channels. The idea in Pub/Sub is that as an action occurs and Publishes a message, another system may be Subscribed to that type of message. Then the subscription will receive the event and do something with it.

In most cases, this whole system is used in the following type of interaction:

1. User 1 adds an item to a collection via the regular HTTP interaction in a web app.
2. User 2 is on the collection’s `index` page viewing the content.
3. User 1’s change is saved and then published through a message bus.
4. User 2’s browser is subscribing to collection update events and get’s the published message.
5. User 2’s browser updates the collection to include the new item without User 2 having to reload the page.

Another way to wrap your head around this type of interaction is to think of a chat room. If the chat were hosted in a web application, it would use Pub/Sub to push those changes around.

On the Server side of things, there are two main pieces for ActionCable. First, there are Connections, which when a WebSocket is accepted from the client, the server creates a Connection object. The Connection object’s main focus is going to be Authentication and Authorization.

On the other hand, Channels are the workhorse of the Server-Side components. Channel’s will be the logical separation of business in the Rails WebSocket world. Once added, an end user can be subscribed to a channel to receive events based on each channel.

On the Client-side of the world, there is a Consumer. This consumer will allow the browser to create a subscription to any of the Channel’s built. There is also the idea that you can call subscribe on the same channel multiple times. This approach is useful if you need to subscribe to numerous instances of a model, for example, multiple individual `Standups` on the page.

This becomes apparent when you involve the Channel’s stream_frommethod. Where the first argument is the string that will match what a later broadcast will publish too. Also, Rails and ActionCable come with a better helper method stream_forif you are working directly with model and objects. This will handle serialization of object ids.

Lastly, as far as ActionCable pieces go, to complete the roundtrip is Broadcasts. A Broadcastis merely the mechanism to publish events back to channels in which there is a subscription.

When it comes to configuration, ActionCable is much like most other Rails libraries and doesn’t need much. Like a database, you can tell ActionCable what type of adapter to use. The adapter choices are async(inline, used for development or testing), Redis(used for production) or PostgreSQL(does not seem very prevalent in the ActionCable use case). ActionCable also allows you to whitelist the URL origin’s from which can connect via WebSockets to ActionCable. You’ll want to set this up when you go live with ActionCable in production to make sure that the WebSocket(ws:// by the way) are coming from the URL your application is using.

Using ActionCable in your application

Let’s get hypothetical for a second here. Let’s suppose you are building a Project Management application. For brevity, also let’s assume you are only concerned with edit’s to the Projects high level information and a notification dropdown. A few more assumptions to be made are that you plan on using Redis, Puma to pass ws:// connections through the main Rails server, Sidekiq, and finally that Devise is being used for authentication.

Let’s get started with some general configuration.

The first thing you can do is added the ActionCable meta tags to your application layout in the head section:

= action_cable_meta_tag

Next, add the following line, to mount ActionCable, to your config/application.rb :

config.action_cable.mount_path = '/websocket'

If you want to see ActionCable work in your development server, you will need to adjust your config/cable.yml :

development:
adapter: redis
url: redis://127.0.0.1:6379/1

If this change is made, you will not be able to pass broadcasts between different processes.

Rails uses their Connection class to handle authorizing and authentication. In the case of using Devise, there is a variable to access and use for authentication. Update app/channels/application_cable/connection.rb :

module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
logger.add_tags 'ActionCable', current_user.id
end
protected
def find_verified_user
if (verified_user = env['warden'].user)
verified_user
else
reject_unauthorized_connection
end
end
end
end

Lastly, in most recent version of Rails, there should be premade cables.js file:

(function() {
this.App || (this.App = {});
App.cable = ActionCable.createConsumer();
}).call(this);

This will create an App object for your Rails javascript to use and add the ActionCable consumer library.

With that out of the way, we can begin with ActionCable based updates to existing Standups when someone edits their standup! In practice, adding ActionCable functionality comes down to a few main parts:

  • A channel in `app/channels` to handle the streaming setup logic from the backend. This means you are making a subscription available from the server to connections from a browser.
  • A javascript/CoffeeScript file in `app/assets/javascripts/channe`\linebreak`ls` that will `subscribe` to streams on the server side.
  • Optional: A service Object to coordinate calls to possibly many background jobs to handle an ActionCable Broadcast.
  • An ActiveJob job that will gather any more needed data render a partial and send partial in the broadcast.
  • A partial that will be the correct amount of markup that will be pushed to the browser via an ActionCable broadcast.
  • The javascript/CoffeeScript file from before will need a `received` function to handle the incoming data(HTML) and place it on the page where it needs to go.

That list may seem daunting, but I am sure we can make our way through it easily. Let’s start by adding our first channel:

class ProjectsChannel < ApplicationCable::Channel
def follow(data)
stream_from "projects:#{data['project_id']}"
end
def unfollow
stop_all_streams
end
end

Here, we are setting up methods that will be invoked from javascript ActionCable functions. When called, they will set up streams that will broadcast back changes, if the browser is subscribed.

With a few mentions of the javascript file while explaining the Channel, it is now time to show you the corresponding javascript file for this channel:

$(document).on('turbolinks:load', function() {
if (App.projects) {
return App.projects.followVisibleprojects();
}
});
App.projects = App.cable.subscriptions.create("projectsChannel", {
collection: function() {
return $('.project-box');
},
connected: function() {
return setTimeout((function(_this) {
return function() {
return _this.followVisibleprojects();
};
})(this), 1000);
},
followVisibleprojects: function() {
var i, len, results, project, projects;
projects = this.collection().map(function() {
return $(this).attr('data-project');
}).get();
if (projects.length > 0) {
results = [];
for (i = 0, len = projects.length; i < len; i++) {
project = projects[i];
results.push(this.perform('follow', {
project_id: project
}));
}
return results;
} else {
return this.perform('unfollow');
}
},
disconnected: function() {
return this.perform('unfollow');
},
received: function(data) {
var box;
console.log("[ActionCable] [project] [" + data.id + "]", data);
box = $(".project-box[data-project='" + data.id + "']");
if (box) {
return box.find('.box-body').first().replaceWith(data.html);
}
}
});

Here, there are a few moving pieces. First, let’s assume you have added a data-project HTML attribute to each surrounding div to the main content you have. They will be collected to be iterated over and send subscriptions to the previous channel with each of the project’s IDs. Additionally, as the projects watched will be dynamic based on the page the end user is visiting, we use a Turbolinks event to refresh the streamed list on every page load. You will see later, the received function will allow incoming rendered HTML passed back to the browser to be inserted in a project’s div .

You’ll want to make sure that your project’s index page is set up to use partials:

- projects.each do |project|
= render partial: 'projects/project', locals: {project: project}

This way, the Project’s can be rendered individually later in a ActiveJob task and pushed to the browser, to replace the current content. The partial wi’ll just be the single item’s representation of the markup. Think of a show template boiled down to just the object’s markup.

Next, you will want to update your controller to invoke some sort of ActionCables side effect after updating the project. In my opinion, this is a great place for a service object to orchastrate the needed changes after the update(or create later) are finished:

...
def update
if @project.update(project_params)
invoke_cables
redirect_back(
fallback_location: root_path,
notice: 'Project was successfully updated.'
)
end
end
...
private
...
def invoke_cables
CableServices::NotifyJobsService.(
project: @project,
action: action_name.to_sym,
user: current_user
)
end
end

If the project updates successfully, a service object is called with the project, action taken(controller action_name) and current_user.

Let’s jump into that service, app/services/cable_services/notify_jobs_service.rb :

module CableServices
class NotifyJobsService
attr_reader :project, :action, :user
def initialize(params)
@project = params[:project]
@action = params[:action]
@user = params[:user]
end
def self.call(params)
new(params).send(:perform)
end
private
def perform
if action == :update
Cables::ProjectItemDomJob.perform_later(project)
end
end
end
end

Right now the Service is simply instantiating an object and passing it to a background job at app/job/cables/project_item_dom_job.rb :

module Cables
class ProjectItemDomJob < ApplicationJob
def perform(project)
ActionCable.server.broadcast(
"projects:#{project.id}",
id: project.id,
html: render_project(project)
)
end
private
def render_project(project)
ApplicationController.render(
partial: 'projects/project',
locals: { project: project }
)
end
end
end

Pretty simple background job we have here. We’re firing off a re-rendered version of that particular project’s partial, via an ActionCable Brodcast. The first argument in a broadcast like this is the string representation of the subscribed channels from the browser. Which, now brings us back to the received function, which will insert the new HTML into the page.

received: function(data) {
var box;
console.log("[ActionCable] [project] [" + data.id + "]", data);
box = $(".project-box[data-project='" + data.id + "']");
if (box) {
return box.find('.box-body').first().replaceWith(data.html);
}
}

Handling Notifications via ActionCable:

Unlike the first example of ActionCable interactions, this one will not be based on a specific page. This provides a few more liberties when subscribing and streaming.

For brevity here, let’s assume there is already some sort of notification dropdown or area, that prefills information based on a query of the last n number of projects.

First, the channel can use stream_for to stream against a specific model object, and in this case, the current user:

class WebNotificationsChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
def unsubscribed
stop_all_streams
end
end

Additionally, the subscribed method is used as it is actually fired without any special invocation as soon as a javascript subscription is created from the browser.

You will also notice a much smaller subscription javascript file:

App.cable.subscriptions.create("WebNotificationsChannel", {
box: function() {
return $('#notification-container');
},
disconnected: function() {
return this.perform('unfollow');
},
received: function(data) {
console.log("[ActionCable] [Notification]", data);
return this.box().first().html(data.html);
}
});

This file is mainly to capture the containing div , HTML replacement logic and some general subscription cleanup.

Yow now jump back to the service object created earlier to add a new background job invocation:

...
def perform
if action == :update
Cables::ProjectItemDomJob.perform_later(standup)
else
Cables::ProjectNotificationJob.perform_later(user)
end
end
...

With only two action types right now, you can use a simple if statement to switch on which jobs should be fire. If you were handling deletes as well, you would probably want to use a case statement. Her’e that new Cables::ProjectNotificationJob :

module Cables
class ProjectNotificationJob < ApplicationJob
include ProjectsHelper
def perform(user)
users = user.teams.flat_map(&:users)
users.each do |notification_user|
WebNotificationsChannel.broadcast_to(
notification_user,
html: render_project(notification_user)
)
end
end
private
def render_project(user)
ApplicationController.render(
partial: 'layouts/navigation/notifications',
locals: { notification_projects: notification_projects(user) }
)
end
end
end

A few more assumptions are made here, such as that user’s belong to one or more teams, and you have a way to fetch a collection of projects through the notification_projects method located in ProjectsHelper . This way the logic could be shared with a template and this job, through an include . Otherwise, this job is very similar to the other in this post.

There is one more important difference, the broadcast is being invoked by `WebNotificationsChannel` and a `broadcast_to` method. This allows you to pass in an object instead of building a string yourself. This is much like the Channel passing in an object in `stream_for`.

Testing ActionCable

Last, but not least, we will want to test the ActionCable behavior added in this post. Unfortunately, at the time of writing, the Rails core team has not added a comprehensive testing class for ActionCable. Thus, their recommended approach is to use behavior testing. In the case of RSpec and this post, it means we will add some feature tests to cover this behavior. To round out our testing, we will add job specs to test the jobs in depth, on a unit level.

One small piece of configuration is needed before getting started with the feature specs. To test ActionCable through the browser, the default feature spec rails server(WebBrick) will not work. Thus, we will add a config setting to tell Capybara to use Puma on demand. Before the end of the Rspec configuration in spec/rails_helper.rb:

config.before(:each, puma: true) do
Capybara.server = :puma
end

This block switches the Capybara server when puma: trueis added to a spec in the same fashion as js: true.

Now, let’s start with the edit feature spec, to go over some helpful approaches to feature spec’n ActionCable:

require 'rails_helper'
RSpec.feature 'ActionCable Edit Project', type: :feature do
login_user
before(:each) do
@team = FactoryGirl.create(:team)
@projects = [
FactoryGirl.create(
:project,
user_id: @user.id
)
]
end
it 'should see project edit via ActionCable', js: true, puma: true do
visit projects_path
project_text = 'Oh yeah!'
expect(page).not_to have_content(project_text)
# submit form in new window
new_window = open_new_window
within_window new_window do
visit edit_project_path(@projects.first)
first('.edit_project .box .links a').click
find('.edit_project .box .nested-fields input.form-control.input-lg')
.set project_text
click_on 'Save'
end
expect do
switch_to_window(windows.first)
page.to have_text(project_text)
end
visit root_path
end
end

The gist of this spec is that, a project is created, available on an index page. Using Capybara’s window management, a new window is opened up to edit the existing project. Finally, after switching back to the first window, see that ActionCable had updated the page without reloading. The final visit root_path makes sure the disconnect logic is run and accounted for.

Behavior testing the notification is practically the same thing and is left as an exercise to the user.

In addition to testing the browser behavior, we can also test the job functions as we expect and renders out a partial to be sent back to the browser:

Now, let's start with the edit feature spec:
```ruby
require 'rails_helper'
include ActiveJob::TestHelper
RSpec.describe Cables::ProjectItemDomJob do
before(:each) do
@user = FactoryGirl.create(:user)
@team = FactoryGirl.create(
:team,
user_ids: [@user.id]
)
@project = FactoryGirl.create(
:project,
user_id: @user.id
)
end
it 'matches with enqueued job' do
ActiveJob::Base.queue_adapter = :test
Cables::ProjectItemDomJob.perform_later
expect(Cables::ProjectItemDomJob).to have_been_enqueued
end
it 'enqueues a default based job' do
ActiveJob::Base.queue_adapter = :test
expect { Cables::ProjectItemDomJob.perform_later(@project) }
.to have_enqueued_job.on_queue('default')
end
it 'renders a partial' do
expect(ApplicationController).to receive(:render)
Cables::ProjectItemDomJob.perform_now(@project)
end
end
```

Here the job is being tested to make sure it is enqueued, enqueued to the right queue and receives a call to render a partial.

Hopefully this post helpers understand ActionCable a little bit more than a quick set of chat functions and methods. Be sure to comment on unique uses you have found for ActionCable!

More by Rob Race

Topics of interest

More Related Stories