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.
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_from
method. 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_for
if 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 Broadcast
is 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.
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: redisurl: 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 ApplicationCableclass Connection < ActionCable::Connection::Baseidentified_by :current_user
def connectself.current_user = find_verified_userlogger.add_tags 'ActionCable', current_user.idend
protected
def find_verified_userif (verified_user = env['warden'].user)verified_userelsereject_unauthorized_connectionendendendend
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:
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 unfollowstop_all_streamsendend
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 updateif @project.update(project_params)invoke_cablesredirect_back(fallback_location: root_path,notice: 'Project was successfully updated.')endend...
private
...
def invoke_cablesCableServices::NotifyJobsService.(project: @project,action: action_name.to_sym,user: current_user)endend
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 CableServicesclass NotifyJobsServiceattr_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 performif action == :updateCables::ProjectItemDomJob.perform_later(project)endendendend
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 Cablesclass ProjectItemDomJob < ApplicationJobdef 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 })endendend
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);}}
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::Channeldef subscribedstream_for current_userend
def unsubscribedstop_all_streamsendend
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 performif action == :updateCables::ProjectItemDomJob.perform_later(standup)elseCables::ProjectNotificationJob.perform_later(user)endend
...
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 Cablesclass ProjectNotificationJob < ApplicationJobinclude 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))endend
private
def render_project(user)ApplicationController.render(partial: 'layouts/navigation/notifications',locals: { notification_projects: notification_projects(user) })endendend
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`.
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) doCapybara.server = :pumaend
This block switches the Capybara server when puma: true
is 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 dologin_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 dovisit projects_path
project_text = 'Oh yeah!'
expect(page).not_to have_content(project_text)
# submit form in new windownew_window = open_new_windowwithin_window new_window dovisit edit_project_path(@projects.first)first('.edit_project .box .links a').clickfind('.edit_project .box .nested-fields input.form-control.input-lg').set project_textclick_on 'Save'end
expect doswitch_to_window(windows.first)page.to have_text(project_text)end
visit root_pathend
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:
```rubyrequire 'rails_helper'include ActiveJob::TestHelper
RSpec.describe Cables::ProjectItemDomJob dobefore(: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' doActiveJob::Base.queue_adapter = :testCables::ProjectItemDomJob.perform_laterexpect(Cables::ProjectItemDomJob).to have_been_enqueuedend
it 'enqueues a default based job' doActiveJob::Base.queue_adapter = :testexpect { Cables::ProjectItemDomJob.perform_later(@project) }.to have_enqueued_job.on_queue('default')end
it 'renders a partial' doexpect(ApplicationController).to receive(:render)Cables::ProjectItemDomJob.perform_now(@project)endend```
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!