上一篇示範了如何做出一個會動的 Channel,接著要來解釋這些是怎麼運作了。在昨天我們修改過的檔案中,排除掉增加網頁元素的 templates/page/index.html.eex
及 JavaScript 打包用的 assets/js/app.js
,還剩下三個,現在我們就來看看每一個檔案所扮演的角色。不過故事得要從另一個檔案講起,這檔案就是整個網頁應用函式的起點: Endpoint。
打開 lib/hello_phx_web/endpoint.ex
,在檔案的第三行,我們可以看到這一句:
socket "/socket", HelloPhxWeb.UserSocket
這一句的意思是當遇到所有以 “/socket” 開頭的 request,就直接把 connection (之後會叫做 socket) 導到 HelloPhxWeb.UserSocket
模組, 不會經過下方的 plug。
就像一般的 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/2
及 id/1
。connect/2
可以用來驗證使用者並給予權限 (之後在後端用於是否能存取某些私有頻道),而 id/1
可以幫每個使用者綁上唯一的 id,當想要將此使用者從所有頻道斷線時,可以用 Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
來達成。
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/3
、terminate/2
、handle_in/3
、 及較少用到的 handle_out/3
跟 handle_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/3
或 broadcast_from/3
來對頻道中的所有人廣播訊息,或是用 push/3
對特定的使用者主動發送訊息。
而回應訊息時,就是在 handle_in/2
裡回傳 {:reply, {status, payloay}, socket}
格式的訊息。若有特殊的情況如果需要非同步的回應訊息時,則要用 reply/2
(但這情況相當少見)。
而故事回到的前端的檔案,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 客戶端實作之外,目前有以下幾種非官方的實作:
join/2
、handle_in/3
處理訊息Happy hacking! 明天見。