Bạn từng trên Facebook nhận notification mà không cần refresh trang? Loại real-time functionality này trên hầu hết app đạt được dùng JavaScript frameworks, như React qua state management. Hầu hết app này hoạt động như single-page applications, vì không yêu cầu page reloading khi dùng để update data real-time. Lंबे thời gian, Rails apps stateless ở chỗ page reload thường required để get current state. Ví dụ, nếu bạn trên Rails app hiển thị list phim rạp và admin thêm phim mới, phim mới không show trên dashboard trừ khi bạn refresh.
Tại sao ActionCable?
ActionCable bridge gap này và làm cho real-time updates và dynamic functionality trong Rails app trở nên khả thi. Nó dùng communication protocol WebSocket để introduce state vào app nhưng vẫn performant và scalable. Với nó, users nhận updated content trên dashboard không cần refresh.
Ma thuật của Turbo-Rails
TurboRails bao gồm turbo drive, turbo frames, turbo streams. Khi request gửi từ phần trang bọc trong turbo-frame, HTML response replace frame nó xuất phát nếu cùng id. Turbo streams cho phép partial page updates qua WebSocket connection. ActionCable broadcast updates từ channel, Turbo stream tạo subscriber cho channel này và deliver updates. Kết quả, asynchronous updates tạo trực tiếp phản hồi model changes.
Chúng ta định xây gì
Mục đích bài này showcase Turbo làm việc với ActionCable behind the scenes broadcast và display real-time updates trong Rails 6 App. Do đó, chúng ta build chat app: chat room tạo bởi bất kỳ user, tất cả users gửi message đến room đó và nhận updates real-time. Cũng cho phép users chat private với nhau. Không implement group chat invites; ngoài phạm vi bài này vì chỉ liên quan additional database designs chứ không phải Turbo/ActionCable. Sau bài này, nên dễ dàng nếu bạn muốn mở rộng.
Nên trông như thế nào, hoặc bao gồm gì?
- Index page liệt kê tất cả chat rooms và users hiện có.
- Trang này dynamically updated khi user mới sign up hoặc room mới tạo.
- Form tạo chat room mới.
- Message chat box tạo message khi ở bất kỳ chat room nào.
7 Bước Dễ Dàng Bắt Đầu
Trong app này, user chỉ login bằng unique username, đạt được dùng sessions.
1. Tạo Rails app mới
rails new chatapp
cd chatapp
2. Tạo user model và migrate
rails g model User username
rails db:migrate
Sau đó, thêm unique validation cho username vì muốn tất cả usernames unique cho owners. Cũng tạo scope fetch all users trừ current user cho user list, vì không muốn user chat với chính mình :).
#app/models/user.rb
class User < ApplicationRecord
validates_uniqueness_of :username
scope :all_except, ->(user) { where.not(id: user) }
end
3. Tạo chat room model
Chat room có name và có thể private (private chats giữa hai users) hoặc public (available cho everyone). Để chỉ ra, thêm column is_private vào room table.
rails g model Room name:string is_private:boolean
Trước khi migrate file này, thêm default value cho column is_private để tất cả rooms tạo mặc định public, trừ khi nói khác.
class CreateRooms < ActiveRecord::Migration[6.1]
def change
create_table :rooms do |t|
t.string :name
t.boolean :is_private, default: false
t.timestamps
end
end
end
Sau bước này, migrate file dùng rails db:migrate. Cũng cần thêm uniqueness validation cho name property và scope fetch all public rooms cho room list.
#app/models/room.rb
class Room < ApplicationRecord
validates_uniqueness_of :name
scope :public_rooms, -> { where(is_private: false) }
end
4. Thêm Styling
Để thêm minimal styling, thêm Bootstrap CDN vào application.html.erb
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
5. Thêm Authentication
Thêm authentication yêu cầu variable current_user mọi lúc. Thêm code sau vào files chỉ định để enable auth.
#app/controllers/application_controller.rb
helper_method :current_user
def current_user
if session[:user_id]
@current_user = User.find(session[:user_id])
end
end
def log_in(user)
session[:user_id] = user.id
@current_user = user
redirect_to root_path
end
def logged_in?
!current_user.nil?
end
def log_out
session.delete(:user_id)
@current_user = nil
end
#app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def create
user = User.find_by(username: params[:session][:username])
if user
log_in(user)
else
render 'new'
end
end
def destroy
log_out if logged_in?
redirect_to root_path
end
end
#app/views/sessions/new.html.erb
<%= form_for (:session) do |f| %>
<%= f.label :username, 'Enter your username' %>
<%= f.text_field :username, autocomplete: 'off' %>
<%= f.submit 'Sign in' %>
<% end %>
Thêm routes sau vào routes.rb.
#routes.rb
Rails.application.routes.draw do
get '/signin', to: 'sessions#new'
post '/signin', to: 'sessions#create'
delete '/signout', to: 'sessions#destroy'
end
6. Tạo Controller
Tạo RoomsController dùng rails g controller Rooms index và thêm variables cho list users và rooms vào index method.
class RoomsController < ApplicationController
def index
@current_user = current_user
redirect_to '/signin' unless @current_user
@rooms = Room.public_rooms
@users = User.all_except(@current_user)
end
end
7. Setup Routes
Thêm room, user và root routes vào routes.rb để landing page là index page list tất cả rooms và users, và navigate đến room bất kỳ.
#routes.rb
resources :rooms
resources :users
root 'rooms#index'
Thiết lập Views
Lần đầu tiên giới thiệu 'ma thuật' của Turbo là nhận real-time updates trên dashboard khi room mới thêm hoặc user mới sign up. Để đạt được, trước hết tạo hai partials: _room.html.erb hiển thị mỗi room và _user.html.erb hiển thị mỗi user. Render list này trong index.html.erb được tạo khi RoomsController tạo, vì đây là landing page.
# app/views/rooms/_room.html.erb
<div>
<%= link_to room.name, room %>
</div>
# app/views/users/_user.html.erb
<div>
<%= link_to user.username, user %>
</div>
Tiến hành render files này trong index.html.erb, không bằng reference trực tiếp mà bằng render variables fetch collection. Nhớ ở RoomsController, variables @users và @rooms đã defined.
#app/views/rooms/index.html.erb
<div class="container">
<h5> Hi <%= @current_user.username %> </h5>
<h4> Users </h4>
<%= render @users %>
<h4> Rooms </h4>
<%= render @rooms %>
</div>
Trong console, chạy lệnh sau:
Room.create(name: 'music')
User.create(username: 'Drake')
User.create(username: 'Elon')
Bật Rails server dùng rails s. Bạn sẽ được nhắc sign in; làm với username bất kỳ user tạo ở trên, bạn sẽ thấy room mới tạo và user bạn không sign in as hiển thị như hình dưới.

Giới thiệu Turbo
Để đạt real-time updates, cần Turbo installed.
bundle add turbo-rails
rails turbo:install
Chạy lệnh sau nếu chưa có Redis:
sudo apt install redis-server #installs redis if you don't have it yet
redis-server #starts the server
Import turbo-rails vào application.js dùng import "@hotwired/turbo-rails"
Tiếp, thêm instructions cụ thể vào models yêu cầu broadcast bất kỳ instance mới thêm vào channel cụ thể. Broadcast này do ActionCable làm, như sắp thấy.
#app/models/user.rb
class User < ApplicationRecord
validates_uniqueness_of :username
scope :all_except, ->(user) { where.not(id: user) }
after_create_commit { broadcast_append_to "users" }
end
Ở đây, yêu cầu user model broadcast đến channel "users" sau mỗi instance user mới tạo.
#app/models/room.rb
class Room < ApplicationRecord
validates_uniqueness_of :name
scope :public_rooms, -> { where(is_private: false) }
after_create_commit {broadcast_append_to "rooms"}
end
Ở đây, cũng yêu cầu room model broadcast đến channel "rooms" sau mỗi instance room mới tạo.
Khởi động console nếu chưa, hoặc dùng reload! nếu đã chạy. Sau khi tạo instance mới của bất kỳ cái nào, ta thấy ActionCable broadcast instance thêm vào channel chỉ định as turbo stream dùng partial assigned làm template. Với room mới thêm, nó broadcast partial _room.html.erb với values tương ứng instance mới, như dưới.

Vấn đề: broadcasted template không show lên dashboard. Vì cần thêm receiver của broadcast vào view để nhận và append. Làm bằng cách thêm tag turbo_stream_from, chỉ định channel hy vọng nhận broadcast. Như hình trên, broadcasted stream có target attribute, chỉ định id của container stream sẽ appended. Nghĩa là broadcasted template sẽ tìm container id "rooms" để append; do đó, include div với id đó vào index file. Để đạt được, trong index.html.erb, thay <%= render @users %> bằng:
<%= turbo_stream_from "users" %>
<div id="users">
<%= render @users %>
</div>
và <%= render @rooms %> bằng
<%= turbo_stream_from "rooms" %>
<div id="rooms">
<%= render @rooms %>
</div>
Lúc này, trải nghiệm ma thuật Turbo. Refresh page và bắt đầu thêm users/rooms từ console và xem chúng append real-time. Yippee!!!
Chán tạo room từ console? Thêm form cho users tạo room mới.
#app/views/layouts/_new_room_form.html.erb
<%= form_with(model: @room, remote: true, class: "d-flex" ) do |f| %>
<%= f.text_field :name, class: "form-control", autocomplete: 'off' %>
<%= f.submit data: { disable_with: false } %>
<% end %>
Form trên dùng @room nhưng chưa defined trong controller; do đó define và thêm vào index method của RoomsController.
@room = Room.new
Khi create button clicked, route đến create method trong RoomsController, chưa tồn tại; do đó thêm nó.
#app/controllers/rooms_controller.rb
def create
@room = Room.create(name: params["room"]["name"])
end
Thêm form vào index file bằng render partial:
<%= render partial: "layouts/new_room_form" %>
Cũng thêm bootstrap classes chia page thành phần list rooms/users và phần chat.
<div class="row">
<div class="col-md-2">
<h5> Hi <%= @current_user.username %> </h5>
<h4> Users </h4>
<div>
<%= turbo_stream_from "users" %>
<div id="users">
<%= render @users %>
</div>
</div>
<h4> Rooms </h4>
<%= render partial: "layouts/new_room_form" %>
<div>
<%= turbo_stream_from "rooms" %>
<div id="rooms">
<%= render @rooms %>
</div>
</div>
</div>
<div class="col-md-10 bg-dark">
The chat box stays here
</div>
</div>
Hình ảnh room thêm real-time
Bây giờ, tạo room mới, thấy room tạo và page update real-time. Bạn cũng nhận ra form không clear sau submit; xử lý sau dùng stimulus.
Group Chats
Cho group chats, cần route đến individual rooms nhưng vẫn same page. Làm bằng cách thêm tất cả index page variables vào RoomsController show method và vẫn render index page.
#app/controllers/rooms_controller.rb
def show
@current_user = current_user
@single_room = Room.find(params[:id])
@rooms = Room.public_rooms
@users = User.all_except(@current_user)
@room = Room.new
render "index"
end
Variable extra @single_room thêm vào show method. Cho particular room được route đến; do đó thêm conditional statement vào index page show tên room khi click room name. Thêm trong div class col-md-10:
<div class="col-md-10 bg-dark text-light">
<% if @single_room %>
<h4 class="text-center">
<%= @single_room.name %>
</h4>
<% end %>
</div>
Hình ảnh di chuyển giữa các room
Tiếp đến phần thú vị: messaging. Cần cho chat section height 100vh fill page và include chat box tạo message. Chatbox cần message model. Model này có user reference và room reference, vì message không tồn tại không có creator và room intended.
rails g model Message user:references room:references content:text
rails db:migrate
Cũng cần identify association trong user và room models bằng thêm line sau:
has_many :messages
Thêm form tạo message và styling:
#app/views/layouts/_new_message_form.html.erb
<div class="form-group msg-form">
<%= form_with(model: [@single_room ,@message], remote: true, class: "d-flex" ) do |f| %>
<%= f.text_field :content, id: 'chat-text', class: "form-control msg-content", autocomplete: 'off' %>
<%= f.submit data: { disable_with: false }, class: "btn btn-primary" %>
<% end %>
</div>
#app/assets/stylesheets/rooms.scss
.msg-form { position: fixed; bottom: 0; width: 90% }
.col-md-10 { height: 100vh; overflow: scroll; }
.msg-content { width: 80%; margin-right: 5px; }
Form include @message variable; do đó define trong controller. Thêm vào show method của RoomsController.
@message = Message.new
Trong routes.rb, thêm message resource trong room resource, vì attaches room id vào params khi message tạo.
resources :rooms do
resources :messages
end
Khi message mới tạo, muốn broadcast đến room nó tạo. Cần message partial render message. Vì đây là gì broadcasted, cũng cần turbo_stream nhận broadcasted message cho room particular và div container cho appending messages. Nhớ id container phải same với target của broadcast.
Thêm vào message model:
#app/models/message.rb
after_create_commit { broadcast_append_to self.room }
Cách này broadcast đến particular room nó tạo.
Thêm stream, message container, message form vào index file:
#within the @single_room condition in app/views/rooms/index.html.erb
<%= turbo_stream_from @single_room %>
<div id="messages">
</div>
<%= render partial: 'layouts/new_message_form' >
Tạo message partial broadcasted, trong đó show username sender chỉ nếu room public.
#app/views/messages/_message.html.erb
<div>
<% unless message.room.is_private %>
<h6 class="name">
<%= message.user.username %>
</h6>
<% end %>
<%= message.content %>
</div>
Từ console, tạo message, thấy broadcast đến room dùng template assigned.

Để enable message creation từ dashboard, thêm create method vào MessagesController.
#app/controllers/messages_controller.rb
class MessagesController < ApplicationController
def create
@current_user = current_user
@message = @current_user.messages.create(content: msg_params[:content], room_id: params[:room_id])
end
private
def msg_params
params.require(:message).permit(:content)
end
end
Kết quả: 
Như video, messages append nhưng chuyển room khác, trông như mất messages room trước khi quay lại. Vì không fetch messages thuộc room để display. Để sửa, trong RoomsController show method, thêm variable fetch all messages thuộc room, và index page render fetched messages. Cũng thấy message form không clear sau gửi; xử lý sau dùng Stimulus.
#in the show method of app/controllers/rooms_controller.rb
@messages = @single_room.messages
#within the div with id of 'messages'
<%= render @messages %>
Bây giờ, mỗi room load messages khi vào.
Làm presentable hơn: align current user messages right, others left. Cách đơn giản: assign classes dựa trên condition message.user == current_user, nhưng local variables không available cho streams; do đó, broadcasted message không có current_user. Làm sao? Assign class cho message container dựa trên message sender id, rồi dùng current_user helper method thêm style vào application.html.erb. Cách này, nếu current user id là 2, class trong style tag application.html.erb là .msg-2, tương ứng class trong message partial khi sender là current user.
#app/views/messages/_message.html.erb
<div class="cont-<%= message.user.id %>">
<div class="message-box msg-<%= message.user.id %> ">
<% unless message.room.is_private %>
<h6 class="name">
<%= message.user.username %>
</h6>
<% end %>
<%= message.content %>
</div>
</div>
Thêm message-box styling:
#app/assets/stylesheets/rooms.scss
.message-box {
width: fit-content;
max-width: 40%;
padding: 5px;
border-radius: 10px;
margin-bottom: 10px;
background-color: #555555 ;
padding: 10px
}
Trong head tag application.html.erb.
#app/views/layouts/application.html.erb
<style>
<%= ".msg-#{current_user&.id}" %> { background-color: #007bff !important; padding: 10px; }
<%= ".cont-#{current_user&.id}" %> { display: flex; justify-content: flex-end }
</style>
Thêm !important tag cho background-color vì muốn override background color cho current user.
Chats trông như này: 
Private Chat
Phần lớn work private chats đã làm trong group chat setup. Còn lại:
- Tạo private room cho private chat khi route đến user particular nếu room không tồn tại.
- Tạo participants cho rooms đó để intruder không send messages, ngay cả từ console.
- Ngăn private rooms mới broadcast đến room list.
- Hiển thị username user thay vì room name khi private chat.
Khi route đến user particular, current user muốn private chat với user đó. Do đó, trong UsersController, check private room tồn tại giữa hai user. Nếu có, thành @single_room; không thì tạo. Tạo special room name cho mỗi private chat room để reference khi cần. Cũng include tất cả variables index page cần trong show method để same page.
#app/controllers/users_controller.rb
class UsersController < ApplicationController
def show
@user = User.find(params[:id])
@current_user = current_user
@rooms = Room.public_rooms
@users = User.all_except(@current_user)
@room = Room.new
@message = Message.new
@room_name = get_name(@user, @current_user)
@single_room = Room.where(name: @room_name).first || Room.create_private_room([@user, @current_user], @room_name)
@messages = @single_room.messages
render "rooms/index"
end
private
def get_name(user1, user2)
users = [user1, user2].sort
"private_#{users[0].id}_#{users[1].id}"
end
end
Như trên, cần thêm method create_private_room vào Room model, tạo private room và participants. Dẫn đến tạo model mới Participant, chỉ user và private room họ belong.
rails g model Participant user:references room:references
rails db:migrate
Trong room model, thêm create_private_room method và đổi after_create call chỉ broadcast room name nếu không phải private room.
#app/models/room.rb
has_many :participants, dependent: :destroy
after_create_commit { broadcast_if_public }
def broadcast_if_public
broadcast_append_to "rooms" unless self.is_private
end
def self.create_private_room(users, room_name)
single_room = Room.create(name: room_name, is_private: true)
users.each do |user|
Participant.create(user_id: user.id, room_id: single_room.id)
end
single_room
end
Để ngăn users khác send messages đến private room khi không phải participants, thêm before_create check vào Message model. Vì vậy, private rooms, confirm sender thực sự participant của private conversation trước khi message tạo. Lưu ý từ dashboard, impossible send message đến private room nếu không phải participant vì chỉ cần click username và room tạo cho cả hai. Check này extra security, vì message có thể tạo bởi non-participant từ console.
#app/models/message.rb
before_create :confirm_participant
def confirm_participant
if self.room.is_private
is_participant = Participant.where(user_id: self.user.id, room_id: self.room.id).first
throw :abort unless is_participant
end
end
Để hiển thị username user thay vì room name khi private chat, chỉ ra trên index page nếu variable @user present, show username user. Variable này chỉ present trong UsersController show method. Dẫn đến h4 tag show room name đổi thành:
<h4 class="text-center">
<%= @user&.username || @single_room.name %>
</h4>
Bây giờ, navigate đến user, thấy username user thay vì room name, và send/recieve messages. Đừng quên thêm sign-out link vào home page.
<%= link_to 'Sign Out', signout_path, :method => :delete %>
Như dưới, private chats không show username sender. Chúng ta identify own messages bằng position.

Stimulus
Dùng stimulus clear forms vì full-page re-render không xảy ra, forms không clear trên new model instance creation.
bundle add stimulus-rails
rails stimulus:install
Thêm files như hình dưới. 
Tạo file reset_form_controller.js reset forms và thêm function:
//app/javascript/controllers/reset_form_controller.js
import { Controller } from "stimulus"
export default class extends Controller {
reset() {
this.element.reset()
}
}
Thêm data attribute vào forms chỉ định controller và action.
data: { controller: "reset-form", action: "turbo:submit-end->reset-form#reset" }
Ví dụ, form_with tag message form đổi thành:
<%= form_with(model: [@single_room ,@message], remote: true, class: "d-flex", data: { controller: "reset-form", action: "turbo:submit-end->reset-form#reset" }) do |f| %>
Cuối cùng, chỉ cần thế; forms clear sau tạo message hoặc room mới. Cũng lưu ý stimulus action "ajax:success->reset-form#reset" cũng clear form khi event ajax:success xảy ra.
Kết luận
Trong app này, tập trung vào append action của Turbo Streams, nhưng không phải tất cả Turbo Streams. Thực tế, Turbo Streams gồm năm actions: append, prepend, replace, update, remove. Để implement xóa và update chat messages real-time, actions này handy, và knowledge về Turbo frames và cách hoạt động có thể cần. Quan trọng lưu ý: app phụ thuộc WebSocket updates cho features, trên kết nối kém hoặc server issues, WebSocket có thể disconnect. Do đó, khuyên dùng Turbo Streams chỉ khi cực kỳ quan trọng.