Please note that this post is not a tutorial and it requires knowledge of Rails 5
ActionCable
and ReactJS
/ Javascript
custom library building.
(please note that this short post will not show you how to build this front-end component though)
One of the awesome features that comes with Rails 5
is ActionCable
. With ActionCable
, you can build all the real-time features you can think of via websocket. While struggling to build a chat system, I had found multiple examples on the ‘net of how to build a chat app with Rails 5
ActionCable
but they are extreme simple to even apply the concept for any real life chat application. I believe this is the first example on the internet that shows you how to build such a chat system with:
- Rails 5 API backend and a ReactJS frontend
- Multiple private rooms
- Any positive number of users in a room (not just 1–1) or group chat
The chat system my talented friend Tim Chang and I have built has:
- Multiple private chat rooms
- Multiple chat users per room
- Online / Offline status of each user
- Real-time “typing…” status
- Real-time read receipt
In this short post, I’ll show you only the basic of #1 and #2. Please leave me a comment below if you want me to show you how to build #3, #4 and #5. I’m using Rails 5 as the back-end API and ReactJS library on the front-end.
Backend
On creation, Rails will generate the channels folders and files where all the real-time magic happens :)
app/channels/application_cable/channel.rb
app/channels/application_cable/connection.rb
Authentication
First of, let’s authenticate the websocket connection requests to your Rails server inside connection.rb.
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
# or however you want to verify the user on your system
access_token = request.params[:'access-token']
client_id = request.params[:client]
verified_user = User.find_by(email: client_id)
if verified_user && verified_user.valid_token?(access_token, client_id)
verified_user
else
reject_unauthorized_connection
end
end
end
end
Depending on the authentication gem or service that you use in your project, find_verified_user
method should be modified to your need. I have a method called valid_token?
to verified the access-token
and client_id
passed in with the websocket request. If the request is not authenticated, then it will be rejected.
Data Structure
The idea is very basic: a chat room that has multiple messages, each message has a content and a sender. Note that a message doesn’t have a “receiver”. This allows a room to have any number of users since you don’t need to care about the receiver of the messages, since all the messages from the senders will end up appearing in a room regardless of how many participants in the room. So, this is the data structure that I use:
- Conversation (room): has_many messages, users and has an id
- Message: belongs_to a conversation, has a sender, has the text content
- Sender: is a User
As a result, I created 3 models:
# message.rb
class Message < ApplicationRecord
belongs_to :conversation
belongs_to :sender, class_name: :User, foreign_key: 'sender_id'
validates_presence_of :content
after_create_commit { MessageBroadcastJob.perform_later(self) }
end
# conversation.rb
class Conversation < ApplicationRecord
has_many :messages, dependent: :destroy
has_and_belongs_to_many :users
end
# user.rb
class User < ApplicationRecord
has_and_belongs_to_many :conversations, dependent: :destroy
end
Action triggers
When a client connects (subscribed) or broadcasts a message (speak), the backend will react with actions. Inside folder app/channels
, I will create a file called room_channel.rb
.
# room_channel.rb
class RoomChannel < ApplicationCable::Channel
# calls when a client connects to the server
def subscribed
if params[:room_id].present?
# creates a private chat room with a unique name
stream_from("ChatRoom-#{(params[:room_id])}")
end
end
# calls when a client broadcasts data
def speak(data)
sender = get_sender(data)
room_id = data['room_id']
message = data['message']
raise 'No room_id!' if room_id.blank?
convo = get_convo(room_id) # A conversation is a room
raise 'No conversation found!' if convo.blank?
raise 'No message!' if message.blank?
# adds the message sender to the conversation if not already included
convo.users << sender unless convo.users.include?(sender)
# saves the message and its data to the DB
# Note: this does not broadcast to the clients yet!
Message.create!(
conversation: convo,
sender: sender,
content: message
)
end
# Helpers
def get_convo(room_code)
Conversation.find_by(room_code: room_code)
end
def get_sender
User.find_by(guid: id)
end
end
As you can see in the comment, after a client “speaks”, the broadcasting is not happening yet; only a new Message is created with its content and data. The chain of action happens after the Message is saved in the DB. Let’s take a look again in the Message model:
after_create_commit { MessageBroadcastJob.perform_later(self) }
Scalability
This callback is called only after the Message is created and committed to the DB. I’m using background jobs to process this action in order to scale. Imagine that you have thousands of clients sending messages at the same time (this is a chat system, why not?), using background job is a requirement here.
# message_broadcast_job.rb
class MessageBroadcastJob < ApplicationJob
queue_as :default
def perform(message)
payload = {
room_id: message.conversation.id,
content: message.content,
sender: message.sender,
participants: message.conversation.users.collect(&:id)
}
ActionCable.server.broadcast(build_room_id(message.conversation.id), payload)
end
def build_room_id(id)
"ChatRoom-#{id}"
end
end
Here is when the broadcasting happens. ActionCable
will broadcast the payload to the specified room with the provided payload.
ActionCable.server.broadcast(room_name, payload)
Cable Route
You will need to add the /cable websocket route to your routes.rb
so that your client can call this endpoint to broadcast and receive messages.
mount ActionCable.server => '/cable'
And that’s it for the backend side! Let’s take a look at the ReactJS front-end library.
Client Library
Please note that depending on the specifics of your project, you will need to understand the concept of this code in this library and modify it to your needs.
First, install the ActionCableJS
via npm
.
Create a ChatConnection.js
file as one of the services in your ReactJs
app.
// ChatConnection.js
import ActionCable from 'actioncable'
import {
V2_API_BASE_URL,
ACCESS_TOKEN_NAME,
CLIENT_NAME,
UID_NAME
} from '../../globals.js'
function ChatConnection(senderId, callback) {
let access_token = localStorage.getItem(ACCESS_TOKEN_NAME)
let client = localStorage.getItem(CLIENT_NAME)
var wsUrl = 'ws://' + V2_API_BASE_URL + '/cable'
wsUrl += '?access-token=' + access_token + '&client=' + client
this.senderId = senderId
this.callback = callback
this.connection = ActionCable.createConsumer(wsUrl)
this.roomConnections = []
}
ChatConnection.prototype.talk = function(message, roomId) {
let roomConnObj = this.roomConnections.find(conn => conn.roomId == roomId)
if (roomConnObj) {
roomConnObj.conn.speak(message)
} else {
console.log('Error: Cannot find room connection')
}
}
ChatConnection.prototype.openNewRoom = function(roomId) {
if (roomId !== undefined) {
this.roomConnections.push({roomId: roomId, conn: this.createRoomConnection(roomId)})
}
}
ChatConnection.prototype.disconnect = function() {
this.roomConnections.forEach(c => c.conn.consumer.connection.close())
}
ChatConnection.prototype.createRoomConnection = function(room_code) {
var scope = this
return this.connection.subscriptions.create({channel: 'RoomChannel', room_id: room_code, sender: scope.senderId}, {
connected: function() {
console.log('connected to RoomChannel. Room code: ' + room_code + '.')
},
disconnected: function() {},
received: function(data) {
if (data.participants.indexOf(scope.senderId) != -1) {
return scope.callback(data)
}
},
speak: function(message) {
return this.perform('speak', {
room_id: room_code,
message: message,
sender: scope.senderId
})
}
})
}
export default ChatConnection
So here is the hook: in createRoomConnection
, the client will try to connect with (subscribe to) the RoomChannel
we created in the backend, once it’s connected (subscribed), it will stream from the room name ChatRoom-id
(look at room_channel.rb
above again.) Once it’s connected, there are 2 methods that will be called frequently, can you guess which one?
They are: received and speak!
The received method is called when there is a message broadcast to the client from the server, on the opposite, speak is called when the client broadcasts a message to the server.
Voila! That’s it. Again, this is not made to be a ready-to-run-out-of-the-box kind of tutorial because each project is different, but I hope it gives you an idea how to build a chat system with multiple private chat rooms and multiple users per room. Please let me know in the comment section if you have any question.
And please don't forget to hit the love button if you find this helpful to your project!
Top comments (2)
This is amazing! Please continue with this tutorials.
Awesome, please continue with this