Xây dựng ứng dụng chat sử dụng Rails và Server-Sent Events
Như mọi người cũng biết, hiện nay có khá nhiều kỹ thuật để xây dựng 1 app chat, ví dụ như sử dụng Nodejs, Websocket hay cũ hơn nữa là AJAX polling. Bài viết dưới dây sẽ hướng dẫn cách xây dựng 1 ứng dụng chat bằng cách sử dụng 1 kỹ thuật khá mới mà HTML 5 cung cấp đó là Server-Sent Event trên ...
Như mọi người cũng biết, hiện nay có khá nhiều kỹ thuật để xây dựng 1 app chat, ví dụ như sử dụng Nodejs, Websocket hay cũ hơn nữa là AJAX polling. Bài viết dưới dây sẽ hướng dẫn cách xây dựng 1 ứng dụng chat bằng cách sử dụng 1 kỹ thuật khá mới mà HTML 5 cung cấp đó là Server-Sent Event trên Rails. Vậy trước tiên ta cần tìm hiểu chút về Server-Sent Event.
Server-Sent Event là gì ?
Nói ngắn gọn, Server-Sent-Event(SSE) là 1 thư viện HTML 5 cho phép ta lấy những dữ liệu cập nhật từ server xuống máy trạm. Nó rất tốt trong trường hợp trang web muốn liên tục cập nhật các thông tin mới từ server theo thời gian thực. Và hiện nay hầu hết trình duyệt đều hỗ trợ Server-Sent Events, ngoại trừ IE.
Xây dựng 1 app chat sử dụng SSE và Rails
Trong ứng dụng chat này, dưới đây ta sẽ tập trung vào SSE tuy nhiên ta cũng sẽ thực hiện một số bước sau:
- Chỉ có member đã đăng ký mới được gửi và đọc message
- Authentice bằng Facebook
- Hiển thị Avatar, nickname ( lấy thông tin từ facebook ) và ngày giờ post comment
- bắt validate cho message
- Dùng SSE để gửi và nhận message mà ko cần load lại trang.
Trước tiên ta sẽ thực hiên nhanh việc xâu dựng 1 ứng dụng rails thông thường bao gồm các bảng User, Comment.
Khởi tạo project và thực hiên Authenticate
Ứng dụng của chúng ta sẽ dùng Puma Web Server, nó không hỗ trợ cho sqlite nên ta sẽ dùng postgres :
$ rails new sse-chat --database=postgresql
Tiếp đến ta tạo bảng user để lưu các thông tin của user bao gồm:
- provider : tên của social network dùng để authenticate, ở đây ta dùng facebook
- name: tên hiển thị của user, ở đây ta cũng lấy từ facebook
- profile_url : đường dẫn profile của user
- avatar_url
- uid: id của user trên facebook
$ rails g model User name:string avatar_url:string provider:string profile_url:string uid:string
Add index cho bảng User, mở xxx_create_users.rb , ta thêm vào cuối hàm change
add_index :users, :uid add_index :users, :provider add_index :users, [:uid, :provider], unique: true
Migrate db:
$ rake db:migrate
Ứng dụng của ta sẽ authentica bằng Facebook thông qua omniauth-facebook, ta đăng ký 1 app facebook để lấy App_id và App_secret.
Thêm vào Gemfile:
gem 'omniauth-facebook'
Cài đặt Gem
$ bundle install
Trong Facebook app, đổi giá trị Site URL thành http://localhost:3000
Tạo file omniauth.rb trong config/initializers để thiết lập các thông tin truy cập app facebook
Rails.application.config.middleware.use OmniAuth::Builder do provider :facebook, 'Your Facebook Key', 'Your Facebook Secret', scope: 'public_profile', display: 'page', image_size: 'square' end
Khai báo routes trong config/routes.rb
get '/auth/:provider/callback', to: 'sessions#create' get '/auth/failure', to: 'sessions#auth_fail' get '/sign_out', to: 'sessions#destroy', as: :sign_out
Bây giờ ta sẽ tiến hành viết hàm trong SessionsController để thực hiện authenticate như sau ( Bài này ta sẽ không đi vào giải thích chi tiết chức năng này và code cũng khá dễ hiểu )
class SessionsController < ApplicationController def create user = User.from_omniauth(request.env['omniauth.auth']) cookies[:user_id] = user.id flash[:success] = "Hello, #{user.name}!" redirect_to root_url end def destroy cookies.delete(:user_id) flash[:success] = "See you!" redirect_to root_url end def auth_fail render text: "You've tried to authenticate via #{params[:strategy]}, but the following error occurred: #{params[:message]}", status: 500 end end
Và trong User model, ta khai báo hàm from_omniauth để lấy thông tin user từ facebook và lưu vào db.
class << self def from_omniauth(auth) provider = auth.provider uid = auth.uid info = auth.info.symbolize_keys! user = User.find_or_initialize_by(uid: uid, provider: provider) user.name = info.name user.avatar_url = info.image user.save! user end end
Đến đây ta đã thực hiện xong việc authenticate cho user. Cần nói thêm là ứng dụng này ta dùng style bootstrap cho đơn giản, trong bài viết này sẽ ko đề cập cụ thể đến view và style ( Bạn có thể xem chi tiết và tham khảo trong source code ) Thêm 1 bước quan trọng trong hầu hết các app là ta khai báo biến @current_user. ta thực hiện trong applications_controller
def current_user @current_user ||= User.find_by(id: cookies[:user_id]) if cookies[:user_id] end helper_method :current_user
Tạo bảng comment
Bảng comment ta tạo đơn giản chỉ gồm 2 trường body và user_id (trường id, created_at, updated_at là mặc định)
rails g model Comment body:text user:references rake db:migrate
Trong model User tạo quan hệ bảng
has_many :comments, dependent: :delete_all
Thêm validate cho comment, nội dung mesage không được để trống và quá 2000 ký tự
validates :body, presence: true, length: {maximum: 2000}
và 1 hàm để format lại thời gian post comment, ta sẽ gọi nó trong view.
def timestamp created_at.strftime('%-d %B %Y, %H:%M:%S') end
Và function để save comment trong controller
def create if current_user @comment = current_user.comments.build(comment_params) @comment.save end end
Bây giờ ta sẽ thiết lập 1 web server hỗ trợ multithread để dùng SSE. Ứng dụng rails của chúng ta hiện đang sử dụng web sẻver default WEBrick tuy nhiên nó lại ko hỗ trợ multithread nên ta sẽ sử dụng 1 web servẻ khác, ở đây ta dùng puma. Ta vào Gèmile và thay thế gem 'thin' bằng gem 'puma' và bundle install.
Tiếp đó ta sẽ config 1 chút ( để tìm hiểu kỹ về puma và cách config ta có thể xem chi tiết tại đây
Tạo file config/puma.rb
workers Integer(ENV['PUMA_WORKERS'] || 3) threads Integer(ENV['MIN_THREADS'] || 1), Integer(ENV['MAX_THREADS'] || 16) preload_app! rackup DefaultRackup port ENV['PORT'] || 3000 environment ENV['RACK_ENV'] || 'development' on_worker_boot do # worker specific setup ActiveSupport.on_load(:active_record) do config = ActiveRecord::Base.configurations[Rails.env] || Rails.application.config.database_configuration[Rails.env] config['pool'] = ENV['MAX_THREADS'] || 16 ActiveRecord::Base.establish_connection(config) end end
Ở đây chỉ là ứng dụng demo nên ta thiết lập các thông số như trên, khi lượng user truy cập lớn hơn ta cần phải update lại chúng.
Và để load được file này, ta cần tạo 1 Procfile ở thư mục gốc của ứng dụng
Procfile
web: bundle exec puma -C config/puma.rb
Tiếp đến ta cần thay đổi giá trị config.eager_load và config.cache_classes để sự dụng streaming và SSE. Sau khi thay đổi các giá trị này, ta cần khởi động lại server.
config/environments/development.rb
config.cache_classes = true config.eager_load = true
Streaming
bây giờ ta sẽ thêm hàm streaming cho web server. ta cần update 1 chút trong routes và controller. Để thực hiện stream, ta cần thêm hàm index trong CommentController và Rails 4 có ActionController::Live hỗ trợ việc dùng streaming SSE. Ta chỉ việc add module này vào controller
class CommentsController < ApplicationController include ActionController::Live
Ta cần set kiểu response trả về là text/event-stream :
def index response.headers['Content-Type'] = 'text/event-stream'
Bây giờ hàm index của ta đã khai báo function streaming, ta cần thêm cơ chế gọi hàm này mỗi khi có bất kỳ 1 comment mới.
def index response.headers['Content-Type'] = 'text/event-stream' sse = SSE.new(response.stream) begin Comment.on_change do |data| sse.write(data) end rescue IOError # Client Disconnected ensure sse.close end render nothing: true end
Trong hàm trên, ta khai báo biến sse để khởi tạo stream. on_change dùng để bắt sự kiện mỗi khi có bất kỳ 1 comment mới nào (ta sẽ khai báo trong comment model).rescue IOError sẽ raise lên lỗi mỗi khi user bị đisconnect.
ensure luôn luôn được gọi để đóng kết nỗi giải thread khi thực hiện xong việc đọc ghi dữ liệu.
Tiếp theo ta cần thực hiện NOTIFY messages. Để làm việc đó ta khai báo after_create callback.
after_create :notify_comment_added . . . private def notify_comment_added Comment.connection.execute "NOTIFY comments, 'data'" end
Bây giờ ta tiến hành viết hàm on_change đã gọi ở trên.
models/comment.rb
class << self def on_change Comment.connection.execute "LISTEN comments" loop do Comment.connection.raw_connection.wait_for_notify do |event, pid, comment| yield comment end end ensure Comment.connection.execute "UNLISTEN comments" end end
wait_for_notify được dùng để chờ thông báo từ channel, khi thông báo xuất hiện kèm theo data thì ta truyền nó đến hàm index trong controller.
Comment.on_change do |data| sse.write(data) end
data ở đây chính là comment.
Subscribing Event Source
Ta thực hiên trong comments.coffee
source = new EventSource('/comments')
event listener onmessage được gọi và thực hiện khi 1 dữ liệu mà không có sự kiện có tên được gửi đến. Ta thêm 1 số login trong comment.coffee. Disable nút submit khi user đã gửi tin.
source.onmessage = (event) -> $('#comments').find('.media-list').prepend($.parseHTML(event.data)) $('.media-body').emoticonize() jQuery -> $('#new_comment').submit -> $(this).find("input[type='submit']").val('Sending...').prop('disabled', true) return
Ở đây ta sẽ sử dụng việc truyền data dưới dạng HTML. Ta sẽ truyền comment ID để get dữ liệu và cho hiển thị lên view.
private def notify_comment_added Comment.connection.execute "NOTIFY comments, '#{self.id}'" end
Và controller
def index response.headers['Content-Type'] = 'text/event-stream' sse = SSE.new(response.stream) begin Comment.on_change do |id| comment = Comment.find(id) t = render_to_string(partial: 'comment', formats: [:html], locals: {comment: comment}) sse.write(t) end rescue IOError # Client Disconnected ensure sse.close end render nothing: true end
Ở đây ta dùng render_to_string để lưu kết quả dưới dạng string, ta cũng cần khai báo format.
Như vậy ta đã xây dựng xong 1 ứng dụng chat sử dụng Rails và Server Sent Event .Vì đây là 1 kỹ thuật khá mới và mạnh nên hi vọng nó sẽ được áp dụng trong nhiều ứng dụng sau này.
Source code tham khảo: https://github.com/ngocthang/sse-chat
Bạn cũng có thể xem Demo