Event Notifications in Rails 5 with ActionCable!
Here at Onehub, we have an activity log for actions users perform. That way you can see when Workspaces, files and folders are created, moved, deleted, or downloaded. You can also see when comments and messages are made as well as many other events.
Currently, we maintain a separate project that handles streaming these events to your activity log. This project is a piece of legacy software (read: hard to maintain and confusing to trace). With the release of Rails 5.0.0beta2, we took a dive into one of Rails 5 most exciting new features, ActionCable.
ActionCable greatly simplifies the complexity of getting websockets up and running to stream real-time event notifications to the activity log. We can now have our activity log logic live right alongside our application logic.
Based on a video tutorial by DHH for a chat app, I put together a short tutorial on how to get an event system up and running in the new Rails 5 app. This tutorial assumes you have created a new Rails 5.0.0beta2 (installing Rails 5 here) project and have a redis server up and running (installing redis).
Lets make some events!
First we’re going to make our event model. An event will have a message, and we’ll use it’s created_at field for the timestamp
$ rails g model event message:string
This will create our migration and our model as well as some test files. We’re going to skip testing in this tutorial, but you shouldn’t in your application!
Now we get to run a command new to Rails!
$ rails db:migrate
It feels weird not typing rake db:migrate but you’ll get over that.
Now let’s create a controller and view so we can look at our events.
$ rails g controller events index
In our events_controller.rb we’ll fetch our events and display them in reverse chronological order.
class EventsController < ApplicationController
def index
@events = Event.all.reverse
end
end
Next we'll create a partial in views/events/_event.html.erb that displays the timestamp of the event and the message.
<%= event.created_at.to_formatted_s(:short) %>: <%= event.message %>
And we'll create the index view.
Activity
<%= render @events %>
And while we're at it we'll setup our routes to point to this activity log.
Rails.application.routes.draw do
root to: 'events#index'
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
# Serve websocket cable requests in-process
# mount ActionCable.server => '/cable'
end
Notice a little hint of where we're headed on that last line?
Now let's start up our rails server. By default it's Puma in Rails 5! Talk about speed!
Go to your http://localhost:3000. OK nothing interesting there yet. Let's pop into the rails console and create ourselves a test event.
> Event.create message: "hello world!"
Refresh the browser and.... ok that's better. But having to refresh is annoying, what if we could just handle this via... oh I don't know maybe... ActionCable?
Enter our superhero ActionCable!
Before unpacking (our Action Cables), we need to turn on ActionCable in cable.coffee by uncommenting the following lines:
@App ||= {}
App.cable = ActionCable.createConsumer()
We'll also need to uncomment that line in routes.rb so we can connect to the ActionCable server. So make sure you have the following line in routes.
mount ActionCable.server => '/cable'
Now we're going to generate a channel, which defines our websocket and how our series of tubes connects the client to the application.
$ rails g channel activity
create app/channels/activity_channel.rb
create app/assets/javascripts/channels/activity.coffee
The first file here handles our server-side logic while the second is our client-side javascript. Let's setup our activity_channel.rb. Since we want to stream from our activity channel it's pretty clear what we can do here.
# Be sure to restart your server when you modify this file. Action Cable runs in an EventMachine loop that does not support auto reloading.
class ActivityChannel < ApplicationCable::Channel
def subscribed
stream_from "activity_channel"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end
Because we probably don't want everyones' activity through the whole app you'd want to do something like "activity_channel-#{account.id}" to quiet down the noise. But we're going to skip that to simplify things.
Now on the client-side when we're going to receive a message containing an event and then we'll prepend that event to the events in the activity log. So let's setup some basic javascript to do that in our activity.coffee.
App.activity = App.cable.subscriptions.create "ActivityChannel",
connected: ->
# Called when the subscription is ready for use on the server
disconnected: ->
# Called when the subscription has been terminated by the server
received: (event) ->
# Called when there's incoming data on the websocket for this channel
$('#events').prepend "#{event.message}"
Sweet... but none of this actually works yet.
One thing DHH covers in his tutorial is the fact that in a production application you'll want to use jobs to handle the broadcasting of messages. This is true for Onehub because we have A LOT of events and we don't want to block up our app with them. So let's create a background job that gets queued up when we create an event that's tasked with broadcasting our message.
$ rails g job EventBroadcast
create app/jobs/event_broadcast_job.rb
Our job will use ActionCable.server.broadcast with our 'activity_channel' and send our a message with our rendered partial we created earlier. (Thanks to DHH for that suggestion, you can see his comments on caching the template in the video linked earlier.)
class EventBroadcastJob < ApplicationJob
queue_as :default
def perform(event)
ActionCable.server.broadcast 'activity_channel', message: render_event(event)
end
private
def render_event(event)
ApplicationController.renderer.render(partial: 'events/event', locals: { event: event })
end
end
Lastly, we'll add in an after_create_commit hook to perform this job after an event has been successfully saved to our database.
class Event < ApplicationRecord
after_create_commit { EventBroadcastJob.perform_later self }
end
Great now that that's all piped together, let's hook this up to something more interesting, like a comment. We'll take the easy approach here and just scaffold it.
$ rails g scaffold comment comment:string
Great! Now let's use that fancy new rails migrate command.
$ rails db:migrate
And let's go hook an event to a comment in the comment model.
class Comment < ApplicationRecord
after_create_commit { create_event }
private
def create_event
Event.create message: "A new comment has been created"
end
end
Start up your server, go to the activity log in few browsers (just so you can see the magic of all the connections). And open a tab to http://localhost:3000/comments/new and create a new comment and watch all the activity logs get updated via the magic of ActionCable!
Great! That's just the tip of the ice berg with Rails 5's new ActionCable. Here we've shown a one-way push from the server to the connected clients to dynamically update content in an activity log. It's also possible to push events from the client to the server via a channel and trigger broadcasts (much like a chat app). But we'll save that for extra-credit and/or another tutorial.
Hope you enjoyed this tutorial. We've uploaded this code to github so you can download it and play around.