Mini-chat với Rails và Server-Sent Events
Ở bài viết này tôi xin giới thiệu với các bạn một kỹ thuật làm real-time webapp sử dụng Server-Sent Events. Đây là một kỹ thuật có thể được sử dụng để thay thế Web Sockets. Những thứ sẽ được đề cập trong bài viết này: Khái quát về Server-Sent Events (SSE) Sủ dụng Rails 4 ActionController::Li ...
Ở bài viết này tôi xin giới thiệu với các bạn một kỹ thuật làm real-time webapp sử dụng Server-Sent Events. Đây là một kỹ thuật có thể được sử dụng để thay thế Web Sockets.
Những thứ sẽ được đề cập trong bài viết này:
- Khái quát về Server-Sent Events (SSE)
- Sủ dụng Rails 4 ActionController::Live để thực hiện việc streamming
- Những setup cơ bản cho Puma web server
- Sử dụng các tính năng LISTEN/NOTIFY của PostgreSQL để gửi thông báo (notifications)
Khái quát về Server-Sent Events
HTML5 đưa ra một API để làm việc với SSE. Ý tưởng chủ đạo của SSE khá đơn giản: trang web bên phía client sẽ theo dõi (subscribe) một sự kiện (event source) bên phía web server để chờ cập nhật mới từ web server. Trang web bên client không phải request liên tục lên server để cập nhật thay đổi mà những sự thay đổi được gửi tự động về client. Phía client chỉ có thể chờ update từ bên server chứ không thể gửi lại bất cứ thứ gì.
Một điểm yếu của SSE đó là không hỗ trợ IE, tuy nhiên có một vài cách walk-around.
Để thực hiện được một app sử dụng SSE thì cần các bước sau:
- Tạo một event source trên server (thực chất là một action trên controller)
- Tạo tính năng streamming (trong rails có ActionController::Live hộ trợ vấn đề này).
- Gửi thông báo mỗi khi có sự thay đổi nào đó trên dữ liệu để event source có thể thông báo đến client (PostgreSQL LISTEN/NOTIFY và một vài phương án khác có thể thực hiện được điều này)
Planning
Demo ở trong bài viết này là một ứng dụng chat đơn giản với một số requirements như sau:
- User được xác thực thông qua Facebook hoặc twitter, chỉ khi xác thực mới có thể đọc và viết.
- Mỗi comment sẽ show ra tên của user, avatar và link đến trang mạng xã hội mà user sử dụng để đăng ký.
- Mỗi dòng chat sẽ được gửi đến toàn bộ các user tham gia chat ngay khi nó được đăng tải.
Authentication
Đầu tiên khởi tạo một ứng dụng rails:
rails new mini_chat
Để user có thể đăng nhập bằng Facebook hay twitter thì cần phải sử dụng đến phương thức xác thực OAuth2. Ý tưởng chính của OAuth2 là user sẽ nhập thông tin đăng nhập của mình trên các trang web khác (ở đây là Facebook hoặc twitter). User sẽ không bao giờ lộ pasword của mình cho web site thứ 3 và chỉ show ra những thông tin căn bản như tên, profile URL, ảnh đại diện) và một cặp khóa (hay chỉ một khóa tùy từng trang web) được dùng để gọi API (vd: post một message lên Facebook thay cho user).
Khi quá trình xác thực bắt đầu, trang web bên thứ 3 sẽ chuyển một key đặc biệt đại diện cho chính trang đấy. Một loạt các hành động mà trang này muốn thực hiện cũng sẽ được gửi kèm. User sẽ được xem một đoạn hội thoại xác nhận xem có đồng ý hay không. Nếu đồng ý thì user sẽ được redirect ngược lại trang web thứ 3 với các thông tin của mình và một (cặp) key dùng để goi API.
Trong ứng dụng này, ta cần lưu các trường sau của user vào DB:
- provider: tên của trang mạng xã hội được dùng để xác thực
- name: tên của user
- profile_url link đến user profile
- avatar_url link đến ảnh đại diện của người dùng.
- uid một string định danh user trên các trang mạng xã hội
Tạo migration cho User:
rails g model User name:string avatar_url:string provider:string profile_url:string uid:string
Thêm 2 gem sau vào Gemfile:
gem 'omniauth-facebook' gem 'omniauth-twitter'
Tiếp theo, ta cần phải đăng ký ứng dụng này trên Facebook hoặc Twitter (ở bài viết này chỉ thực hiện trên Facebook). Đầu tiên, vào trang [https://developers.facebook.com/appsư và tạo một app mới. Tiếp theo ấn vào "Add platform" và chọn "Website". Điền vào Site URL là http://localhost:3000/ và bên tab "Advance Setting" tìm và điền vào ô "Valid OAuth redirect URIs" giá trị http://localhost:3000/auth/facebook/callback.
Tiếp đến, tạo một file tên là omniauth.rb trong thư mục config/initializers và thêm đoạn cấu hình sau vào:
Rails.application.config.middleware.use OmniAuth::Builder do provider :facebook, ENV["FACEBOOK_KEY"], ENV["FACEBOOK_SECRET"], scope: "public_profile", display: "page", image_size: "square", callback_url: "http://localhost:3000/auth/facebook/callback" end
Ta cũng cần phải tạo các routes tương ứng sau trong config/routes:
get "/auth/:provider/callback", to: "sessions#create" get "/auth/failure", to: "sessions#auth_fail" get "/sign_out", to: "sessions#destroy", as: :sign_out
Bên phía controller:
session_controller
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
request.env['omniauth.auth'] là một hash chứa toàn bộ các thông tin của user và nó còn được gọi là authentication hash.
Sau khi user được tạo (thông qua method from_omniauth sẽ được nói đến sau), id của user sẽ được lưu vào cookie để có thể kiểm tra xem user này đã được xác thực rồi hay chưa.
Quay trở lại với method from_omniauth. Ở đây user sẽ được tạo hoặc tìm kiếm thông qua uid và provider:
class User < ActiveRecord::Base 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.profile_url = info.urls.try(provider.capitalize.to_sym) user.save! user end end end
Comments
Trong mini-chat app này, Comment là object thể hiện các đoạn chat của users. Comment sẽ chứa các trường sau:
- body: nội dung đoạn chat
- user_id: id của user đã đăng đoạn chat
Generate migration:
rails g model Comment body:text user:references
Server setting
Việc cần làm tiếp theo là thiết lập một web server có khả năng hỗ trợ đa luồng, điều kiện tiên quyết đối với SSE. Web server WEBrick mặc định của rails không hỗ trợ đa luồng nên ta cần giải pháp thay thế. Trong mini-chat app này tôi sử dung Puma web server.
Thêm puma vào Gemfile:
gem 'puma'
Tiếp theo là config puma. Tạo một file là puma.rb trong folder config và thêm:
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
Thêm Procfile vào trong thư mục gốc của app với nội dung sau:
web: bundle exec puma -C config/puma.rb
Và bây giờ khi chạy
rails s
Rails server sẽ khởi động thông qua puma
Ta cần phải cấu hình thêm để app có thể hoạt động được trên môi trường development. Đầu tiên là phải thiết lập config.eager_load và config.cache_classes trong config/environments/development.rb thành giá trị true.
Với setting này, các bạn sẽ phải khởi động lại server mỗi khi thay đổi code.
Vì app này sử dụng các tính năng LISTEN/NOTIFY của Postgres nên các bạn cần phải cài và cấu hình ứng dụng để sử dụng.
Streamming
Bây giờ là lúc cài đặt tính năng streamming cho server. Việc đầu tiên cần làm là phải tạo ra một action trên controller và route cho nó:
config/routes.rb
get "/streams", to: "comments#stream"
Do streamming không phải là một restful action nên không nhất thiết phải dùng một trong 7 ham restful có sẵn.
stream action cần phải được trang bị tính năng streamming và trong rails 4 đã có module ActionController::Live được thiết kế dành cho việc này. Thêm module này vào CommentsController:
class CommentsController < ApplicationController include ActionController::Live #[...] end
và thiết lập kiểu trả về là text/event-stream
def stream response.headers['Content-Type'] = 'text/event-stream' #... end
Hàm streamming bây giờ đã có khả năng streamming về browser, tuy nhiên điều server cần làm là nhận biết được sự thay đổi (hay đúng hơn là thêm mới một Comment) trong DB. Postgres DB có hỗ trợ tính năng LISTEN/NOTIFY để giúp ta thực hiện điều này.
Để gửi một thông báo NOTIFY, tạo một after_create callback trong model Comment:
class Comment #[...] after_create :notify_comment_added #[...] private def notify_comment_added Comment.connection.execute "NOTIFY comments, 'data'" end
Ở đây, NOTIFY comments, 'data' được sử dụng để gửi data ra ngoài thông qua kênh comments. data ở đây có thể là bất cứ dự liệu nào từ message mới được tạo ra.
Tiếp theo, ta sẽ tạo method on_change sẽ lắng nghe cập nhật ở kênh comments
comment.rb
class Comment #[...] 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 #[...] end
wait_for_notify được dùng để chờ thông báo trên kênh comments. Ngay khi thông báo và dữ liệu đến, nó sẽ được chuyển đến (dữ liệu sẽ được lưu trong biến comment của block) controller:
comments_controller.rb
def stream 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
Ở đây các bạn có thể thắc mắc là tại sao hàm on_change của Comment lại có một vòng lặp vô hạn. Đó là do tính năng xử lý đa luồng của puma giúp cho hàm stream trên server được chạy trên một thread độc lập so với web app của chúng ta. Chính vì chạy vô hạn vòng lặp như vậy nên SSE mới có khả năng trả ngược dữ liệu về cho client như vậy.
Event Source
Để client theo dõi một event source thì rất đơn giản. Thêm đoạn sau vào app/assets/javascript/comments.js:
source = new EventSource('/comments'); source.onmessage = function(event) { //action with event and/or event.data, which is the data respond from server }
Xử lý kết quả trả về
Như đã nói ở trên thì dữ liệu trả về sẽ bắt nguồn từ đây:
NOTIFY comments, 'data'
Tùy thuộc vào loại dữ liệu trả về mà ta có thể xây dựng các cách response khác nhau trên controller. Các bạn có thể trả về JSON hay đơn giản là trả về comment id. Nếu trả về một JSON thì khi render template trên client sẽ khá phức tạp nếu không sử dụng thư viện hỗ trợ nào. Nếu chỉ trả id và trên controller ta kết hợp với việc render template dưới dạng string rồi stream lại phía client thì mọi việc sẽ đơn giản hơn rất nhiều.
Đầu tiên trong method notify comment ởcomment.rb, ta cần trả về id của comment:
def notify_comment_added Comment.connection.execute "NOTIFY comments, '#{self.id}'" end
Trên controller, thay vì streamming data trực tiếp thì thay vào đó là streamming string template của comment:
def stream #... Comment.on_change do |id| comment = Comment.find(id) t = render_to_string(comment, formats: [:html]) sse.write(t) end #... end
Chú ý: ở đây ta phải thiết lập formats nếu không rails sẽ tìm partial với format là text/event-stream.
Bên phía client, ta thêm xử lý khi nhận được kết quả từ server:
source.onmessage = function(event) { $('#comments').find('.media-list').prepend($.parseHTML(event.data)); }
Kết luận
Để làm việc được với SSE thì cần rât nhiều các kỹ thuật liên quan như Multi Threading Web server (Puma), Streamming controller (ActionController::Live), tính năng LISTEN/NOTIFY hay pub/sub của DB (Postgres). Điều này khiến cho SSE khá phức tạp khi triển khai.
References
Mini-chat with Rails and Server-Sent Events Mini-Chat with Rails