+1

Tạo ứng dụng chat với Rails 5, ActionCable và Devise

1. Giới thiệu

Một trong những tính năng nổi bật của Rails 5 là ActionCable, cho phép tích hợp WebSocket vào ứng dụng và đóng vai trò là phía client với JS và phía server với nền tảng Ruby. Từ đó ta có viết các ứng dụng với đặc điểm thời gian thực.

2. Xây dựng app chat

Cài gem devise để quản lý current_user

gem 'devise'

Tạo phòng chat

Tạo model chat_room.rb

rails g model ChatRoom title:string user:references

Mỗi chat_room thuộc 1 user nên ta có

#models/chat_room.rb
belongs_to :user

User có nhiều chat_room

#models/users.rb
has_many :chat_rooms, dependent: :destroy

Controller để list và tạo chat room

#chat_rooms_controller.rb
class ChatRoomsController < ApplicationController
  def index
    @chat_rooms = ChatRoom.all
  end

  def new
    @chat_room = ChatRoom.new
  end

  def create
    @chat_room = current_user.chat_rooms.build(chat_room_params)
    if @chat_room.save
      flash[:success] = 'Chat room added!'
      redirect_to chat_rooms_path
    else
      render 'new'
    end
  end

  private

  def chat_room_params
    params.require(:chat_room).permit(:title)
  end
end

Tạo chat message

chat_message thuộc 1 user và 1 chat_room, khới tạo chat_message

rails g model Message body:text user:references chat_room:references
#models/message.rb
belongs_to :user
belongs_to :chat_room

Hiển thị chat message trong chat room

#chat_rooms_controller.rb
def show
  @chat_room = ChatRoom.includes(:messages).find_by(id: params[:id])
end

Thêm ActionCable

Phía Client

Trước khi tiến hành xử lý, ta thêm redis để chạy back job

gem 'redis', '~> 3.2'
bundle install

Sửa file config/cable.yml để sử dụng Redis như một adapter

#config/cable.yml
adapter: redis
url: YOUR_URL

Gán ActionCable với một URL trong route

#config/routes.rb
mount ActionCable.server => '/cable'

Tạo xử lý js trong file cable.js và thêm vào trong file application.js

#javascripts/cable.js
//= require action_cable
//= require_self
//= require_tree ./channels

(function() {
  this.App || (this.App = {});

  App.cable = ActionCable.createConsumer();

}).call(this);

Giả sử khách là một client kết nối websocket mà có thể đăng ký một hay nhiều kênh. Mỗi ActionCable Server đảm nhận xử lý nhiều kết nối. Kênh tương đương như một bộ điều khiển của MVC được sử dụng cho streaming.

Tạo một kênh mới:

#javascripts/channels/rooms.coffee
App.global_chat = App.cable.subscriptions.create {
    channel: "ChatRoomsChannel"
    chat_room_id: ''
  },
  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) ->
    # Data received

  send_message: (message, chat_room_id) ->
    @perform 'send_message', message: message, chat_room_id: chat_room_id

Khi đó, chúng ta thực hiện đăng ký một user vào ChatRoomsChannel và nhập room id. Quá trình đăng kỳ này bao gồm các callback: kết nối, hủy kết nối và nhận dữ liệu. Chức năng chính trong quá trình đăng ký là send_message

Trong phòng chat để hiển thị message ta thực hiện xử lý js

room chat:

#views/chat_rooms/show.html.erb
<div id="messages" data-chat-room-id="<%= @chat_room.id %>">
  <%= render @chat_room.messages %>
</div>

Sử dụng room id để hiện thị message

#javascripts/channels/rooms.coffee
jQuery(document).on 'turbolinks:load', ->
  messages = $('#messages')
  if $('#messages').length > 0

    App.global_chat = App.cable.subscriptions.create {
        channel: "ChatRoomsChannel"
        chat_room_id: messages.data('chat-room-id')
      },
      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) ->
        # Data received

      send_message: (message, chat_room_id) ->
        @perform 'send_message', message: message, chat_room_id: chat_room_id

với jQuery(document).on 'turbolinks:load' chỉ chạy được nếu ứng dụng đang sử dụng Turbolink 5

Xử lý của script trên chỉ là kiểm tra nếu có 1 block #message thì đăng ký kênh dựa theo room id. Bươc kế tiếp là thực hiện lắng nghe sự kiện submit

#javascripts/channels/rooms.coffee
jQuery(document).on 'turbolinks:load', ->
  messages = $('#messages')
  if $('#messages').length > 0

    App.global_chat = App.cable.subscriptions.create
    # ...

    $('#new_message').submit (e) ->
      $this = $(this)
      textarea = $this.find('#message_body')
      if $.trim(textarea.val()).length > 1
        App.global_chat.send_message textarea.val(), messages.data('chat-room-id')
        textarea.val('')
      e.preventDefault()
      return false

Khi form được submit, nó sẽ lấy nội dụng mesage, kiểm tra độ dài rồi gọi hàm send_message để gửi mesage mới tới tất cả khách trong phòng chat đó.

Phía Server

Tới đây, nhiệm vụ của ta là phải giới thiệu một kênh cho server. Trong Rails 5, có một thư mục mới gọi là channels để host.

Tạo file chat_rooms_channel.rb:

#channels/chat_rooms_channel.rb
class ChatRoomsChannel < ApplicationCable::Channel
  def subscribed
    stream_from "chat_rooms_#{params['chat_room_id']}_channel"
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end

  def send_message(data)
    # process data sent from the page
  end
end

subscribed là một hàm đặc biệt để bắt đầu streaming từ một kênh với tên được nhập vào. Dựa vào chat_room_id được lấy từ client thông qua chat_room_id: messages.data('chat-room-id') khi đăng ký 1 kênh

unsubscribed là một callback được gọi khi streaming dừng

send_message được gọi khi ta chạy lệnh @perform 'send_message', mesage: message, chat_room_id: chat_room_id từ script client. Biến data chứa data được gửi

  • Phân tán thông điệp được nhận đến các user khác:

Sửa hàm send_message:

#channels/chat_rooms_channel.rb
def send_message(data)
  current_user.messages.create!(body: data['message'], chat_room_id: data['chat_room_id'])
end

Khi server nhận 1 message, thực hiện lưu vào database. Bạn không cần phải check sự tồn tại của chat room, mặc định trong Rails 5 một record cha luôn phải tồn tại thì mới save được.

Có 1 vấn đề là hàm current_user của Devise không sử dụng được. Ta cần phải sửa lại connection.rb trong thư mục application_cable

#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

    protected

    def find_verified_user # this checks whether a user is authenticated with devise
      if verified_user = env['warden'].user
        verified_user
      else
        reject_unauthorized_connection
      end
    end
  end
end

từ đó hàm current_user có giá trị và chỉ khi user được xác thực thì mới có thể được phân phát các message của họ

Tạo một callback được chạy sau khi mesage được lưu vào trong database để đặt lịch một job ngầm.

#models/message.rb
after_create_commit { MessageBroadcastJob.perform_later(self) }
#jobs/message_broadcast_job.rb
class MessageBroadcastJob < ApplicationJob
  queue_as :default

  def perform(message)
    ActionCable.server.broadcast "chat_rooms_#{message.chat_room.id}_channel",
                                 message: 'MESSAGE_HTML'
  end
end

hàm perform thực hiện phân phát mesage, nhưng thế còn data mà chúng ta muốn gửi? ta có thể sử dụng JSON để gửi đến client

#jobs/message_broadcast_job.rb
class MessageBroadcastJob < ApplicationJob
  queue_as :default

  def perform(message)
    ActionCable.server.broadcast "chat_rooms_#{message.chat_room.id}_channel",
                                 message: render_message(message)
  end

  private

  def render_message(message)
    MessagesController.render partial: 'messages/message', locals: {message: message}
  end
end

Quay lại phía Client

Bây giờ server đã hoạt động, JSON đã được gửi về, bây giờ client sẽ xử lý:

#javascripts/channels/rooms.coffee
App.global_chat = App.cable.subscriptions.create {
    channel: "ChatRoomsChannel"
    chat_room_id: messages.data('chat-room-id')
  },
  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) ->
    messages.append data['message']

  send_message: (message, chat_room_id) ->
    @perform 'send_message', message: message, chat_room_id: chat_room_id

Thêm 1 xử lý nhỏ là cửa sổ chat tự động scroll xuống message mới:

#javascripts/channels/rooms.coffee
jQuery(document).on 'turbolinks:load', ->
  messages = $('#messages')
  if $('#messages').length > 0
    messages_to_bottom = -> messages.scrollTop(messages.prop("scrollHeight"))

    messages_to_bottom()

    App.global_chat = App.cable.subscriptions.create
    # ...

scroll xuống dưới ngay khi một message mới đến

#javascripts/channels/rooms.coffee
received: (data) ->
  messages.append data['message']
  messages_to_bottom()

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí