12/08/2018, 12:27

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

0