DEV Community

Matias Carpintini
Matias Carpintini

Posted on • Updated on

Real Time Notification System with Sidekiq, Redis and Devise in Rails 6

DISCLAIMER
Just published a new article on how to do this in Rails 7, with Hotwire.

You can read this one anyways, here i'm going to explain things that in the new one we didn't see ;)


In this post, we are going to talk a lot about asynchronous functions, something very fashionable nowadays, but not so much just a few years ago.

With this I do not mean that it is something necessarily new, but I believe that thanks to the JS ecosystem today the world happens in real time.

In this post, I want to teach the key concepts of this. But as always, we do not stay in theory and we are going to see a real implementation, such as real-time notifications from our application.

I will try to be as brief and simple as possible.

Definitions and concepts -

Asynchronous programming: refers to the occurrence of events that happen in our program. They are executed independently of our main program, and never interfere with their execution. Prior to this, we were forced to wait for a response to continue execution, seriously impairing the user experience.

Concurrency, Parallelism, Threads, Processes, Async and Sync — Related? 🤔

WebSockets: WebSockets represent a long awaited evolution in client/server web technology. They allow a long-held single TCP socket connection to be established between the client and server which allows for bi-directional, full duplex, messages to be instantly distributed with little overhead resulting in a very low latency connection. Keep reading

In other words, it allows us to establish a peer-to-per connection between the client and the server. Before this, the client was only the one who knew where the server was, but not vice versa.

Thanks to this, we can send a request to the server, and continue executing our program, without waiting for your response. Then the server knows where the client is, and can send you the response.

WebSockets Diagram

Let's do it 👊

All of the above already makes sense for our notification system, right?
Before continuing, make sure you have Redis installed. Sidekiq uses Redis to store all of its job and operational data.

👋 If you do not know what Redis is, you can discover it on its official site

Sidekiq helps us to work in the background in a super simple and efficient way. (Also, one of my favorite gems ♥️)

I created this project for this article in order to focus directly on what interests us. The project is a simple blog with user authentication and the necessary front to display our notifications. You can download it and follow the article with me.

NOTE: you can see the full implementation in the "notifications" branch

Init configuration...

In config/routes.rb we will mount the routes of ActionCable (framework for real-time communication over websockets)

Rails.application.routes.draw do
  # everything else...
  mount ActionCable.server => '/cable'
end
Enter fullscreen mode Exit fullscreen mode

Now, do you remember how WebSockets works? A peer-to-peer connection, well in other words, is also a channel (as we call it in Rails), and within that channel, we have to always identify each user. This so that the server can know who to reply to, and know who made a request. In this case, we will identify it with the user.id (I am using devise)

So, in app/channels/application_cable/connection.rb:

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_user
    end

    def find_user
      user_id = cookies.signed["user.id"]
      current_user = User.find_by(id: user_id)

      if current_user
        current_user
      else
        reject_unauthorized_connection
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

We are going to save the user who logged in a cookie (it will help us to obtain it from other places, we will see), an interesting solution for this (at least with Devise) is using Warden Hooks

For that, we can create an initializer on our application, config/initializers/warden_hooks.rb

  Warden::Manager.after_set_user do |user, auth, opts|
    auth.cookies.signed["user.id"] = user.id
    auth.cookies.signed["user.expires_at"] = 30.minutes.from_now
  end

  Warden::Manager.before_logout do |user, auth, opts|
    auth.cookies.signed["user.id"] = nil
    auth.cookies.signed["user.expires_at"] = nil
  end
Enter fullscreen mode Exit fullscreen mode

Now, let's create a table in our database to save every notification we create, for this, $ rails g model Notification user:references item:references viewed:boolean

NOTE: :item is a polymorphic association, I do it this way so they can add various types of notifications)

Let's specify this and other details in our migration (db/migrate/TIMESTAMP_create_notifications.rb):

class CreateNotifications < ActiveRecord::Migration[6.0]
  def change
    create_table :notifications do |t|
      t.references :user, foreign_key: true
      t.references :item, polymorphic: true
      t.boolean :viewed, default: false

      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

and, $ rails db:migrate

In app/models/notification.rb we are going to do a couple of things that we will see on the go

class Notification < ApplicationRecord
  belongs_to :user
  belongs_to :item, polymorphic: true # Indicates a polymorphic reference

  after_create { NotificationBroadcastJob.perform_later(self) } # We make this later

  scope :leatest, ->{order("created_at DESC")}
  scope :unviewed, ->{where(viewed: false)} # This is like a shortcut

  # This returns the number of unviewed notifications
  def self.for_user(user_id)
    Notification.where(user_id: user_id).unviewed.count
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's create a concern, remember that one of the most respected Rails philosophies is DRY (Don't Repeat Yourself), currently, each notification requires the same to work (in models)(again, in this project we only have publications, but We could have many other things that we want to integrate with our notification system, so with this form it is super simple).

For that, app/models/concerns/notificable.rb

module Notificable
  extend ActiveSupport::Concern # module '::'

  included do # this appends in each place where we call this module
    has_many :notifications, as: :item
    after_commit :send_notifications_to_users
  end

  def send_notifications_to_users
    if self.respond_to? :user_ids # returns true if the model you are working with has a user_ids method
      NotificationSenderJob.perform_later(self)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Now we can include it in our app/models/post.rb. Remember that our send_notifications_to_users expects the method user_ids to reply to you with the respective fix. Let's do that (app/models/post.rb):

class Post < ApplicationRecord
  include Notificable
  belongs_to :user

  def user_ids
    User.all.ids # send the notification to that users
  end
end
Enter fullscreen mode Exit fullscreen mode

We are going to create the job in charge of sending the notifications, this is what we will send to the background and we will handle with Sidekiq. For that, $ rails g job NotificationSender

Inside the job (app/jobs/notification_sender_job.rb):

class NotificationSenderJob < ApplicationJob
  queue_as :default

  def perform(item) # this method dispatch when job is called
    item.user_ids.each do |user_id|
      Notification.create(item: item, user_id: user_id)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Finnally, we need to install Sidekiq (and Sinatra for make few things a little more easier), so, in out Gemfile:

# everything else...
gem 'sinatra', '~> 2.0', '>= 2.0.8.1'
gem 'sidekiq', '~> 6.0', '>= 6.0.7'
Enter fullscreen mode Exit fullscreen mode

Don't forget, $ bundle install

We are going to tell Rails that we will use Sidekiq for jobs on the queue adapter (config/application.rb):

# everything else...
module Blog
  class Application < Rails::Application
    # everything else...
    config.active_job.queue_adapter = :sidekiq
  end
end

Enter fullscreen mode Exit fullscreen mode

We are also going to set up the routes that Sidekiq provides us, among them, a kind of backoffice for our background (later you can have acces from localhost:3000/sidekiq), very interesting. In config/routes.rb:

require 'sidekiq/web'
Rails.application.routes.draw do
  # everything else...
  mount Sidekiq::Web => '/sidekiq'
end
Enter fullscreen mode Exit fullscreen mode

Now we are going to create the channel through which we will transmit our notifications. $ rails g channel Notification

In the backend of this channel (app/channels/notification_channel.rb), we will subscribe users:

class NotificationChannel < ApplicationCable::Channel
  def subscribed
    stream_from "notifications.#{current_user.id}" # in this way we identify to the user inside the channel later
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end
end
Enter fullscreen mode Exit fullscreen mode

And in the frontend of the channel (app/javascript/channels/notification_channel.js) it would be interesting to send a push notification to the browser, there are many JS libraries that make that very easy (like this), but in order not to make the post much heavier, we are going to print a simple message on the console. So:

// everything else...
consumer.subscriptions.create("NotificationChannel", {
  // everything else...
  received(data) {
    if(data.action == "new_notification"){
      cosole.log(`New notification! Now you have ${data.message} unread notifications`) 
    } // we will define action & message in the next step
  }
});
Enter fullscreen mode Exit fullscreen mode

At this point, we already have a lot running, let's send that notification to the user! For this we are going to create another job that just does this, remember, the previous job is in charge of creating the notifications, this one does the broadcast. So, $ rails g job NotificationBroadcast

Inside app/jobs/notification_broadcast_job.rb:

class NotificationBroadcastJob < ApplicationJob
  queue_as :default

  def perform(notification)
    notification_count = Notification.for_user(notification.user_id)

    ActionCable.server.broadcast "notifications.#{ notification.user_id }", { action: "new_notification", message: notification_count }
  end
end
Enter fullscreen mode Exit fullscreen mode

Fantastic, we already have everything working! 🎉
I'm going to add a few things to the backend to end the example.

First of all, I'm going to add a method to my user model so I can count the notifications I haven't seen yet. And the model is a good place to do this query. In app/models/user.rb:

class User < ApplicationRecord
  # everything else...
  def unviewed_notifications_count
    Notification.for_user(self.id)
  end
end
Enter fullscreen mode Exit fullscreen mode

I'm also going to create a controller, $ rails g controller Notifications index. Inside the controller (app/controllers/notifications_controller.rb) i'm going to add some methods:

class NotificationsController < ApplicationController
  def index
    @notifications = Notification.where(user: current_user).unviewed.leatest

    respond_to do |format|
      format.html
      format.js
    end
  end

  def update
    @notification = Notification.find(params[:id])

    message = @notification.update(notification_params) ? "Viewed notification" : "There was an error"

    redirect_to :back, notice: message
  end

  private
  def notification_params
    params.require(:notification).permit(:viewed)
  end
end
Enter fullscreen mode Exit fullscreen mode

I will create a js view to be able to respond remote and display the latest notifications in my dropdown in the nav. In app/helpers/notifications_helper.rb:

module NotificationsHelper
  def render_notifications(notifications)
    notifications.map do |notification|
      render partial: "notifications/#{notification.item_type.downcase}", locals:{notification: notification}
    end.join("").html_safe
  end
end
Enter fullscreen mode Exit fullscreen mode

Add the link in your nav, in my case (app/views/partials/notifications.html.erb):

<%= link_to notifications_path, remote: true, data:{ type:"script" } %>
Enter fullscreen mode Exit fullscreen mode

Let's not forget to add the paths (app/config/routes.rb) for this new controller.

# everything else...
Rails.application.routes.draw do
  # everything else...
  resources :notifications, only: [:index, :update]
end
Enter fullscreen mode Exit fullscreen mode

Just create a partial for this item (like app/views/notifications/_post.rb). They can include a link to 'mark as seen', in this way:

<%= link_to notification_path(id: notification, notification:{viewed: true}), method: :put %>
Enter fullscreen mode Exit fullscreen mode

To run this locally you will have to run Redis ($ redis-server) and Sidekiq ($ bundle exec sidekiq) + $ rails s, have 3 terminal windows open with these 3 commands running in parallel.

That is all, I hope it is useful to you 👋

Top comments (1)

Collapse
 
anandrmedia profile image
Anand Sukumaran

Great article. Effort in developing a notification system is often overlooked. From the surface, it looks simple, but as we get into the complex delivery logic, it's often very difficult to scale. It's for the same reason why we built our product - Engagespot for developers.

Your article made me reminded of those things :)