Hired is a predominately Rails based application – the “majestic monolith” as DHH would call it. It’s five years old, but we take staying up to date very seriously. Up to date software makes the Hired app faster and more secure. We updated our application to Rails 5 earlier this year, but haven’t gotten around to taking advantage of many of the new features. The biggest addition in Rails 5 was integrated WebSockets via ActionCable, so given that it had been 15 months or so since the initial release, I decided it was probably time to check out ActionCable and see if I could quickly hack together a new feature using it.
For those new to WebSockets, a WebSocket is a communication protocol that allows for back and forth communication over a single TCP connection. Before WebSockets, you needed to continuously poll the server to see if there was any new information available for the user. This generated unnecessary constant requests to your app and realistically only allowed for one-way communication.
Around 2010, the WebSocket spec was published which defined an API for creating persistent connections to a server where the client and/or server could sent data at any time. Rails 5 added a new framework called ActionCable that provides developers a client-side Javascript framework and server-side Ruby framework to make writing real-time features in Rails easy and fun. So let’s take a look at how it works!
So first off what should we build? For those who haven’t used Hired yet, we have two types of users: Employers and Candidates. Candidates apply to the platform and then choose when they want to go live and start accepting interview requests from Employers. The Employers get matched to Candidates and can search and find Candidates they want to request an interview with. There are a few views we work with: a search style list interface showing matching Candidates, and the individual profile pages for each Candidate. With that in mind, let’s build something super simple into the interface: “online now” indicators so Employers can see when Candidates they’re viewing are online.
First off, we’ll assume you already have a Rails 5 project you’re working with, so I won’t go into installing or starting a project. To get started with ActionCable, we can use the Rails generator to get us moving:
`$ > rails generate channel OnlineIndicator`
This will create some files in your project:
create app/channels/ create app/assets/javascripts/cable.js create app/assets/javascripts/channels/online_indicator.coffee create app/channels/application_cable/channel.rb create app/channels/application_cable/connection.rb
Let’s have a look here and see what we get:
// app/assets/javascripts/cable.js //... //= require action_cable //= require_self //= require_tree ./channels (function() { this.App || (this.App = {}); App.cable = ActionCable.createConsumer(); }).call(this);
This file just includes the action cable js files and readies a consumer to connect to the cable on your server once you’ve specified a subscription.
# app/assets/javascripts/channels/online_indicator.coffee App.online_indicator = App.cable.subscriptions.create "OnlineIndicatorChannel", 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 channe
This file prepares some callback methods for the three main interactions the consumer can have with the channel: connecting, disconnecting, and receiving data.
# app/channels/online_indicator_channel.rb class OnlineIndicatorChannel < ApplicationCable::Channel def subscribed # stream_from "some_channel" end def unsubscribed # Any cleanup needed when channel is unsubscribed end end
This class creates the OnlineIndicator channel and prepares two methods that allow us to execute code when the subscription is created and destroyed.
The `ApplicationCable::Channel` and `ApplicationCable::Connection` classes that are created are empty for now.
But first, let’s think about how something like this might work. An Employer views a list of Candidates and wants to be informed whenever one of those Candidates connects or disconnects from the application. So we’ll probably need to subscribe to a channel that broadcasts when a user comes online or goes offline. We’ll probably also need to store some state for the initial page loads so that we don’t need to re-broadcast events any time a client connects.
Let’s start to fill in the Ruby side here. The first thing we need to do is not intuitive but we need to tell ApplicationCable how to identify a user, and if you’re using Devise like we are, you also need to do a few little tricks to find your user using signed cookies. I had to do some digging to figure this out so consider yourself saved.
# 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.email end private def find_verified_user verified_user = User.find_by(id: cookies.signed['user.id']) if verified_user verified_user else reject_unauthorized_connection end end end
And we should set those cookies somewhere, so let’s modify our Warden initializer:
# config/initializers/warden.rb Warden::Manager.after_set_user do |user, auth, opts| scope = opts[:scope] auth.cookies.signed["#{scope}.id"] = user.id auth.cookies.signed["#{scope}.expires_at"] = 30.minutes.from_now end Warden::Manager.before_logout do |user, auth, opts| scope = opts[:scope] auth.cookies.signed["#{scope}.id"] = nil auth.cookies.signed["#{scope}.expires_at"] = nil end
This sets or destroys the signed cookie when a user logs in or out.
We also need to modify our `config/cable.yml` file slightly if we want this to work in development. By default, Rails sets the adapter to `async`, which only operates in the same process, so you can’t easily check whether things are working by broadcasting messages from console or another browser window. So let’s change our adapter to redis:
# config/cable.yml development: adapter: redis
Now that that’s out of the way, we can start writing some real code!
When a User connects to our app, we need to store their status somewhere so we can know whether to show the indicator on page load, so let’s just use Redis to store that. We also need to broadcast that they’ve connected to the channel and subscribe them so they will be notified when others come and go:
# app/channels/online_indicator_channel.rb class OnlineIndicatorChannel < ApplicationCable::Channel def subscribed # store in redis that they’re online $redis.set “users:online:#{ current_user.id }”, 1 # broadcast to the channel that they've appeared OnlineIndicatorChannel.broadcast('online_indicator', { userId: current_user.id, action: ‘appeared’ }) # subscribe to the stream stream_from 'online_indicator' end def unsubscribed # remove the redis key indicating they’re online $redis.del “users:online:#{ current_user.id }” # broadcast to the users channel that they've disappeared OnlineIndicatorChannel.broadcast_to('online_indicator', { userId: current_user.id, action: ‘disappeared’ }) end end
In our `user.rb` file, we can now add a helper method to check whether they’re marked as online.
# app/models/user.rb def online_now? $redis.get(“users:online:#{ id }”).present? end
We can use that when we initially load the page to show or/hide the indicator. Let’s see what a quick mock of our view might look like:
/ app/views/users/_user.haml = div_for(user) do = image_tag user.image_url = user.name .online-indicator{ class: !user.online_now? && 'xs-none' } Online Now!
Now that we have our view, we can use Javascript to handle the live updates. When the User connects/disconnects, we’ll broadcast that they’ve come online or gone offline. Let’s go fill in the functions in our channel coffee file. Since we don’t really have anything we need to do in the view when a client connects or disconnects, we’ll just log the event. We’re only concerned when other users have come on or offline and it’s streamed to other connected Users. The received method is what really matters here:
# app/assets/javascripts/channels/online_indicator.coffee App.online_indicator = App.cable.subscriptions.create "OnlineIndicatorChannel", connected: -> console.log 'User subscribed to channel' disconnected: -> console.log 'User unsubscribed to channel' received: (data) -> $indicator = $('#user-' + data.userId + ' .online-indicator') if data.action == 'appeared' $indicator.removeClass('xs-none'); else if data.action == 'disappeared' $indicator.addClass('xs-none'); else console.log 'unknown action'
When our client receives the message on the channel that a User has come online or gone offline, we find the online indicator DOM element if the User is on the page and hide or show it with CSS. Pretty simple!
And there you have it. With a little HTML/CSS styling you have just implemented a simple way to show when users go online and offline with WebSockets!