iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 27
1
Software Development

函數式編程: 從 Elixir & Phoenix 入門。系列 第 27

Channel.part_2

上一篇示範了如何做出一個會動的 Channel,接著要來解釋這些是怎麼運作了。在昨天我們修改過的檔案中,排除掉增加網頁元素的 templates/page/index.html.eex 及 JavaScript 打包用的 assets/js/app.js,還剩下三個,現在我們就來看看每一個檔案所扮演的角色。不過故事得要從另一個檔案講起,這檔案就是整個網頁應用函式的起點: Endpoint。

Channel 的各個角色概觀

Endpoint

打開 lib/hello_phx_web/endpoint.ex ,在檔案的第三行,我們可以看到這一句:

socket "/socket", HelloPhxWeb.UserSocket

這一句的意思是當遇到所有以 “/socket” 開頭的 request,就直接把 connection (之後會叫做 socket) 導到 HelloPhxWeb.UserSocket 模組, 不會經過下方的 plug。

UserSocket

就像一般的 request 在 Endpoint 的最後會把 connection 導入 router 一樣,這個 HelloPhxWeb.UserSocket,就是扮演 socket 的 router 的角色。

# lib/hello_phx_web/channels/user_socket.ex

defmodule HelloPhxWeb.UserSocket do
  use Phoenix.Socket

  ## Channels
  channel "room:*", HelloPhxWeb.RoomChannel

  ## Transports
  transport :websocket, Phoenix.Transports.WebSocket
  # transport :longpoll, Phoenix.Transports.LongPoll

  # Socket params are passed from the client and can
  # be used to verify and authenticate a user. After
  # verification, you can put default assigns into
  # the socket that will be set for all channels, ie
  # ...
  # See `Phoenix.Token` documentation for examples in
  # performing token verification on connect.
  def connect(_params, socket) do
    {:ok, socket}
  end

  # Socket id's are topics that allow you to identify all sockets for a given user:
  #
  #def id(socket), do: "user_socket:#{socket.assigns.user_id}"

  def id(_socket), do: nil
end

在檔案上方,我們可以用 channel "頻道:子頻道", 模組名稱 來定義某個頻道的訊息要導向哪一個後端的 channel 模組。

transport 可以選擇不同的訊息傳輸方式,預設是 websocket,但也可以使用 longpoll 來處理。

另外前端 client 在連線時,會觸發 connect/2id/1connect/2 可以用來驗證使用者並給予權限 (之後在後端用於是否能存取某些私有頻道),而 id/1 可以幫每個使用者綁上唯一的 id,當想要將此使用者從所有頻道斷線時,可以用 Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 來達成。

Channel

Channel 是位於後端的檔案,可以想做是一般 request 中的 Controller 角色。

defmodule HelloPhxWeb.RoomChannel do
  use HelloPhxWeb, :channel

  def join("room:lobby", payload, socket) do
    if authorized?(payload) do
      {:ok, socket}
    else
      {:error, %{reason: "unauthorized"}}
    end
  end

  def handle_in("ping", payload, socket) do
    {:reply, {:ok, payload}, socket}
  end

  def handle_in("shout", payload, socket) do
    broadcast socket, "shout", payload
    {:noreply, socket}
  end

  # Add authorization logic here as required.
  defp authorized?(_payload) do
    true
  end
end

Channel 上可以實作五種 callback 函式 join/3terminate/2handle_in/3 、 及較少用到的 handle_out/3handle_info/2,channel 會依情況觸發這些 callback。

  • join/3 用於當使用者自 client 發送加入頻道訊息時,驗證 socket 是否有權利加入此頻道 ( 利用我們在 UserSocket 裡放進去的 user_id )。

  • handle_in/3 會在 client 端發送訊息時,用 pattern matching 找到正確的函式,並執行相應的動作。例如我們在上一篇的實作中發送的 shout 訊息,就是呼叫了這個檔案中的第二個 handle_in,進而觸發廣播事件。

  • handle_out/3 一般來說不會實作這個 callback,但某些情況下,你會想要在回傳所有的訊息到 client 端之前,做一些統一的處理,但這麼做會對效能有顯著的負面影響。詳情可以看文件裡的 Phoenix.Channel intercept/1 的說明。

  • terminate/2 則是用於處理頻道關閉,或是使用者自行離開所要進行的相應後續行為。

而在每個 callback 中,你可以使用 broadcast/3broadcast_from/3 來對頻道中的所有人廣播訊息,或是用 push/3 對特定的使用者主動發送訊息。

而回應訊息時,就是在 handle_in/2 裡回傳 {:reply, {status, payloay}, socket} 格式的訊息。若有特殊的情況如果需要非同步的回應訊息時,則要用 reply/2 (但這情況相當少見)。

socket.js

而故事回到的前端的檔案,socket.js 負責把 socket 建起來及連線。接著在使用者進入頁面時,發送加入頻道的訊息。而剩下的,就是開發者要處理操作網頁上的元件時觸發事件,接著用 channel.push 對後端發送訊息了。

import {Socket} from "phoenix"

let socket = new Socket("/socket", {params: {token: window.userToken}})
socket.connect()

/** 我們寫的 **/
let channel = socket.channel("room:lobby", {})
let chatInput         = document.querySelector("#chat-input")

chatInput.addEventListener("keypress", event => {
  if(event.keyCode === 13){
    channel.push("shout", {body: chatInput.value})
    chatInput.value = ""
  }
})

let messagesContainer = document.querySelector("#messages")
channel.on("shout", payload => {
  let messageItem = document.createElement("li");
  messageItem.innerText = `[${Date()}] ${payload.body}`
  messagesContainer.appendChild(messageItem)
})
/** 結束 **/

/* 加入 "room:lobby" 頻道並處理後續 */
channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) })

export default socket

歷史、效能及其它

如同上一篇所說,Phoenix 最初想解決的問題之一就是高併發的訊息傳遞。自 2013 發布以來,Channel 就是整個框架的重要組成。利用了 Erlang / Elixir 的 process,及建構於其上的 PubSub 設計,Channel 不管在開發上及效能上都非常優異。

所謂的效能優異的證明,雖然是個不公平*的比試,但 DockYard 曾在 2016 年夏天使用 Rails 的 ActionCable (在 2015 開始實作,自 Rails 5 併入),一起在 Digital Ocean 16GB, 8 核的 instance 上實作多人聊天室進行評測。

ActionCable 在 8 個房間,每間 200 個使用者,也就是總數 1600 個使用者時,廣播訊息到所有的使用者接收為止,後期的使用者耗費超過 10 秒。而在 9 個房間, 總數 1800 個使用者時,就開始出現訊息無法送達的情況了。

Channel 則是在 275 個房間,總數 55,000 個使用者時,平均的廣播時間約 0.24 秒。最久的訊息耗費 0.6 秒左右。之所以沒有測試什麼時候會掉訊息,是因為併發測試工具 tsung 的極限就是 55,000 條連線,無法再增加了。

這個評測的詳細資訊,可以看看 DockYard 所寫的 Phoenix Channels vs Rails Action Cable

Note *: 所謂的不公平,指的是 Ruby 基於先天的 GIL 機制,本來就不擅長平行運算。

Channel 不只能用在網頁上,有許多的第三方套件可以讓你用不同的客戶端透過 websocket 與 Phoenix 應用程式溝通。除了官方的 JavaScript 客戶端實作之外,目前有以下幾種非官方的實作:

重點回顧

  • Endpoint 會將 “/socket” 連線導向 UserSocket
  • UserSocket 像是 Route,並負責在 socket 放後端用的資料
  • Channel 是後端的檔案,實作 join/2handle_in/3 處理訊息
  • socket.js 是前端的 client,在客戶端操作時觸發事件,對 channel 發送訊息
  • 55,000 使用者,平均廣播時間 0.24 秒
  • 可以跟其它客戶端,如 iOS 、 Android 及 Window phone (還有這種東西嗎) 介接

Happy hacking! 明天見。


上一篇
Channel.part_1
下一篇
Macro 及 web.ex
系列文
函數式編程: 從 Elixir & Phoenix 入門。31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
taiansu
iT邦新手 4 級 ‧ 2018-01-18 02:22:06

已更新

太神啦

taiansu iT邦新手 4 級 ‧ 2018-01-19 01:17:10 檢舉

/images/emoticon/emoticon37.gif

我要留言

立即登入留言