iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

DAY 30
1
自我挑戰組

Metaprogramming Ruby and Rails系列 第 30

Day 30 -- Rails 實作 Action Cable 即時交易功能

Action Cable 毫無疑問地在 Rails的發展史上立下了ㄧ個重要的里程碑,它將 WebSocket 網路傳輸協定整合在 Rails 框架中,以 Pub/Sub 模式讓開發者得以實現即時更新的功能。

接下來需要先了解以下主題:

預計 Model 之間的關係會是這樣:
https://ithelp.ithome.com.tw/upload/images/20201013/20120868Ko6hVp05V7.png

Step 1:建立使用者驗證機制 

參考:快速實作使用者驗證機制
Branch: "feature/Step_1_User_Authentication"

Step 2:建立商品的基本CRUD

Branch: "feature/Step_2_Commodity"

Step 3:加入Action Mailer 寄信功能

參考:實作開發模式 Action Mailer 寄信功能 with Sidekiq
Branch: "feature/Step_3_Action_Mailer"

Step 4:加入 Action Cable 即時功能

Branch: "feature/Step_4_Action_Cable"

Step 5: 部署 Heroku

Branch: "feature/Step_5_Deploy_to_Heroku"
完成後的 Demo

Connection Setup

這裡要先設定伺服器與客戶端的連接的認證方式。

" 連接是客戶端-服務器通信的基礎。每當服務器接受一個WebSocket,就會實例化一個連接對象。所有頻道訂閱(channel subscription)都是在繼承連接對象的基礎上創建的。連接本身並不處理身份驗證和授權之外的任何應用邏輯。WebSocket 連接的客戶端被稱為連接用戶(connection consumer)。每當用戶新打開一個瀏覽器標籤、窗口或設備,對應地都會新建一個用戶-連接對(consumer-connection pair)。" from Action Cable 概覽

# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user
    def connect
      self.current_user = find_verified_user
    end
    private
    def find_verified_user
      verified_user =  User.find_by(id: cookies.signed['user.id'])
      if verified_user && cookies.signed['user.expires_at'] > Time.now       
        verified_user
      else
        reject_unauthorized_connection
      end
    end
  end
end

Channel Setup

這裡 Channel 的概念與 Rails MVC 的 Controller 相近。在預設情況下,Rails 將以 ApplicationCable::Channel為所有頻道的上層,如果有共用的方法或是邏輯就可以放在這裡。

# app/channels/application_cable/channel.rb
module ApplicationCable
  class Channel < ActionCable::Channel::Base
  end
end

接著建立交易平台的頻道,在終端機輸入:rails generate channel board

# app/channels/board_channel.rb
class BoardChannel < ApplicationCable::Channel
  def subscribed
    stream_from "board",
  end
  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end
end

頻道建立完成後,登入後的會員就可以訂閱交易平台的頻道,成為頻道訂閱者(subscriber)便可以收到發布的消息。

Broadcasting

" 廣播是指發布/訂閱的鏈接,也就是說,當頻道訂閱者使用流接收某個廣播時,發布者發布的內容會被直接發送給訂閱者。
廣播也是時間相關的在線隊列。如果用戶未使用流(即未訂閱頻道),稍後就無法接收到廣播 " from Action Cable 概覽

這次所實作的交易平台在三種情況下需要透過『廣播』,讓訂閱者可即時更新平台上的內容:

  1. 會員新增委託單時,交易平台會即時增加該筆委託單
  2. 會員取消委託單時,交易平台會即時移除該筆委託單
  3. 委託單成交時,交易平台會即時移除已成交委託單

因此需要在 Commodity controller 裡的 create 、destroy 及 deal action 廣播

def create
  @commodity = Commodity.new(commodity_params)
  @commodity.user_id = current_user.id
 
  if @commodity.save
    SendCommodityWorker.perform_async(@commodity.id)
    redirect_to commodities_path, notice: "新增成功"
  else
    render :new
  end
end  
--------------------------------------------------------------------
def destroy
  if @commodity.may_cancel?
    RemoveCommodityWorker.perform_async(@commodity.id)
    @commodity.destroy
    @commodity.cancel!
    redirect_to commodities_path, notice: "取消成功"
  else
    redirect_to commodities_path, notice: "此委託單已成交"
  end
end
--------------------------------------------------------------------
def deal
  @commodity = Commodity.find_by(id: params[:id])
  if @commodity && @commodity.trade!
    @commodity.closer_id = current_user.id
    @commodity.save
    RemoveCommodityWorker.perform_async(@commodity.id)
    # 寄給結單使用者
    MailWorker.perform_async(@commodity.closer_id)
    # 寄給掛單委託者
    MailWorker.perform_async(@commodity.user_id)
    # 寄給系統管理員
    MailAdminWorker.perform_async(1, @commodity.user_id,  
    @commodity.closer_id)
    redirect_to commodities_path, notice: "下單成功"
  else
    redirect_to commodities_path, notice: "下單失敗"
  end
end

接著建立兩個 worker 讓 sidekiq 處理背景任務:

# app/workers/send_commodity_worker.rb
class SendCommodityWorker
  include Sidekiq::Worker
  sidekiq_options queue: :default, retry: 3
  def perform(commodity_id)
    commodity = Commodity.find(commodity_id)
    html = CommoditiesController.render(
      partial: 'commodity',
      locals: { commodity: commodity }
      ).squish
    ActionCable.server.broadcast "board", html: html
  end
end

# ---------------------------------------------------------
# app/workers/remove_commodity_worker.rb
class RemoveCommodityWorker
  include Sidekiq::Worker
  sidekiq_options queue: :default, retry: 3
  def perform(commodity_id)
    commodity = Commodity.find(commodity_id)
    html = "commodity_#{commodity.id}"
    closed = CommoditiesController.render(
      partial: 'closed_commodity',
      locals: { commodity: commodity }
    ).squish
    ActionCable.server.broadcast "board", deal: html, closed: closed
  end
end

Subscriptions

" 訂閱頻道的用戶,稱為訂閱者。用戶創建的連接稱為(頻道)訂閱。訂閱基於連接用戶(訂閱者)發送的標識符創建,收到的消息將被發送到這些訂閱。" from Action Cable 概覽

目前交易平台的設計是不允許訂閱者互相傳送訊息,因此不需要有另外的頻道。登入系統後的訂閱者都會收到相同的訊息:

# app > javascript > channels > board_channel.js
import consumer from "./consumer"
consumer.subscriptions.create("BoardChannel", {
  // Called when the subscription is ready for use on the server     
  connected() {
    console.log("Connected to Board channel")
  },
  // Called when incoming data on the websocket for this channel
  received(data) {
   const commodityContainer = document.getElementById('commodities')  
   const closed_commodityContainer = 
   document.getElementById('closed_commodities')
   const commodityRecords = 
   document.getElementsByClassName(data.deal)
   // data['deal'] is equal to data.deal
   // if the "deal" key can't be found in data hash, javascript will  
   // return "undefined"
   // it means the create action is required, and vice versa
   if (data['deal']!= undefined) {
     var i = 0;
     do {
       commodityRecords[i].innerText='';
       i += 1;
     } while (i < commodityRecords.length);
     closed_commodityContainer.innerHTML = data.closed +  
     closed_commodityContainer.innerHTML;
   }
   else
     commodityContainer.innerHTML = data.html + 
     commodityContainer.innerHTML;
  }
});

以上面的程式碼為例:我們用 connected() 方法在 Chrome Console 看出是否有出現 " Connected to Board Channel",有出現的話就代表成功連到 Board channel。

另一個 received(data) 方法則是代表當資料從頻道取得後,要作如何的操作。在這裡可以在 HTML 的 tr 元素裡綁住新增的『商品委託單ID』,再以 class 的方式來選取該筆新增的資料。

 " <tr class='commodity_28'> 
   <td>28</td> 
   <td>買進</td> 
   <td>台積電</td> 
   <td>5</td> 
   <td>$100.0</td> 
   <td>$500.0</td> 
   <td>
     <a data-confirm="確認要賣出嗎?" class="btn btn-outline-primary"  
      rel="nofollow" data-method="post" href="/commodities/28/deal">
      賣出
     </a>
   </td> 
   </tr> "

確認有抓下來該筆資料後,可用 data.html + commodityContainer.innerHTML 把新增的資料加在原有資料表裏的最上方。

Step 5: 部署 Heroku

Branch: "feature/Step_5_Deploy_to_Heroku"

這邊是完成後的 Demo

參考資料:

Action Cable Overview
Action Cable 概覽
Ruby on Rails 中的 Sidekiq 的介紹與應用


上一篇
Day 29 -- Stimulus with Rails 6
下一篇
Ruby 學習筆記簿:Metaprogramming Workshop - Before Action
系列文
Metaprogramming Ruby and Rails33

2 則留言

0
小菜
iT邦新手 4 級 ‧ 2020-10-15 09:16:23

恭喜完賽!!!

kevin0117 iT邦新手 5 級 ‧ 2020-10-30 15:45:59 檢舉

謝謝小菜
終於升級後能回應了

0
run26kimo
iT邦新手 5 級 ‧ 2020-10-30 10:37:14

Demo 壞了 ?

kevin0117 iT邦新手 5 級 ‧ 2020-10-30 15:45:06 檢舉

很抱歉,由於本月我在Heroku上的免費時段已用完,所以要等到11月才能恢復正常了。
謝謝通知

run26kimo iT邦新手 5 級 ‧ 2020-10-30 16:37:17 檢舉

ok thanks ?

我要留言

立即登入留言