Browser 與 Server 持續同步的作法介紹 (Polling, Comet, Long Polling, WebSocket)
對 Comet 的懵懂
記得兩年多前,第一次看到 Gmail 中的 GTalk 覺得很好奇:「咦?線上聊天且是 Google 的熱門系統,只用傳統的 AJAX 應該會操爆伺服器吧?」很幸運的,當時前公司內部的 Tech Talk 就有位同事分享這個叫 Comet 的技術、是種「為了讓瀏覽器與伺服器頻繁溝通所使用的技術、主要的瓶頸在於 WWW 伺服器上。」但因為工作沒有用到這類的需求、加上找不太到好的入門文章、實作的人不多,因此我對 Comet 的認識一直停留在懵懂的階段。
這一年多,會自動更新的網站越來越多,例如 Twitter、Plurk、Facebook 都會隨時有新資料出現在頁面上。也越來越常聽到 nodeJS 這個框架、似乎成為了此類需求的最佳的解決方案。心中的疑問是:「nodeJS 是專門為了實作 Comet 的 Web 伺服器嗎?」(當然不只是這樣 =b)
一頭霧水的實作階段
最近 miiiCasa 需實作一個即時通知的功能:「當有人做了跟我有關聯的動作時(例如:設為聯絡人、上傳照片到我可以存取的設備),立刻會有一則訊息在左下角。」同事分別將 nodeJS 架設起來並做了分享,似乎萬事皆備只欠 Coding。不過真的開始 Coding、尋找文件時就開始混亂了。因先前錯誤的認知,將許多名詞都混在一起: Polling、AJAX Comet、Comet with Iframe、Non-blocking IO、Web Socket、Long Polling、Socket.io 等。而且還發現 nodeJS 的定位跟我想像的差異很大,本來只知道它是一套事件驅動的伺服器端語言、後來才了解它的強大、甚至可寫出不同類型的伺服器(A HTTP Proxy Server in 20 Lines of node.js Code),它的定位對我來說,根本就是另一套不同概念的 Apache + PHP,即時通知只是其中的一種受歡迎的實作罷了。
先把 nodeJS 放一旁吧(畢竟我對它的了解還在幼稚園階段)。這篇文章主要要介紹的是上面提到的混亂名詞,希望用最簡單的實作讓大家了解每個技術的定義、避免混淆在溝通時造成誤解。
1. 老掉牙的輪詢 - Polling
Polling 的範例輪詢最常見也最簡單:「利用 JavaScript 的 setInterval(),每隔一段時間就對 Server 發送一個 Request 以 JSONP 或 AJAX 的方式取得最新的資料。」例如:每隔 3 分鐘向伺服器問一次,檢查目前登入 Cookie 是否過期。
JavaScript 的部份
每秒鐘從 Server 取得資料:
YUI().use("node-base", "io", function (Y) {
Y.on("io:complete", function (id, o, args) {
Y.one("#show").append(o.responseText);
}); // 將 Server 成功 Response 的資料寫到頁面上。
Y.later(1000, null, function () {
Y.io("polling.php");
}, null, true); // 每 1 秒用 XMLHttpRequest 向 polling.php 發送 Request。
});
PHP 的部份
範例只是輸出亂數,你可以想像這是從 memcache 或資料庫中取出了幾筆資料:
$num = rand(10, 100);
echo "Server said $num.rn";
exit();
優點是非常容易實作、沒有跨瀏覽器的問題、也不需要特殊伺服器做配合。而缺點是沒效率,因多數時間的 Request/Response 的 Header/Content 一致但又不能做 Cache,會因此浪費不必要的頻寬。
2. 舊時代的 Comet - 永不停止的連線
Comet 如同我前面所說,已經有兩三年的歷史了,前端的技術完全無新意可言、後端雖然老舊但會有點 Tricky。Comet 的中文是「彗星」的意思,顧名思義發出的 Request 會像彗星的尾巴一般,拉得很長(一般的 Request 立刻就會結束了)。而這樣做的好處就是可以不結束連線,讓 Server 持續地 Response 資料回 Browser,如此一來就可以解決 Polling 造成頻寬浪費的問題。常見作法有以下兩種:
2-1. 用 AJAX 實作 Comet
AJAX Comet 的範例首先,必須在 PHP 動些手腳:在 Server 查詢完畢後、利用 flush() 顯示結果、再使用 sleep() 暫停執行,依這樣的方式做無窮迴圈。這樣的作法可將 Browser 的 Request 減到最低、但 Server 端仍得用無窮迴圈一直做查詢(可以說是 Server 端的 Polling)。
echo str_repeat(" ", 1024); // 本來我沒辦法產生片段輸出的效果,但先輸出 1024 就可以了,真神奇。
while (TRUE) // 無窮迴圈
{
$wait = rand(1, 3);
flush(); // 輸出結果,有人會另外加上 ob_flush()
$num = rand(10, 100); // 一樣是亂數、可以想成是 ,memcache 或資料庫的查詢。
echo "Server said $num.rn";
sleep($wait); // 等待一陣子
}
再看看 JavaScript 的部份,其實就是 XMLHttpRequest(也可以是 Script Tag Hack / JSONP)。因為連線不會結束的關係,我們必須使用 readyState = 3 來對回傳的資料做處理。另外由於 PHP 的 flush() 是一直將 response 增加、不是刷新 response,所以我們必須用 substring 才能取得最新的資料。Hmmm... 有點鳥,但這個問題還算可以接受 :p
var node = Y.one("#show");
try {
var request = new XMLHttpRequest();
} catch (e) {
alert("Browser doesn't support window.XMLHttpRequest");
}
var pos = 0; // 記下目前的輸出總長度
request.onreadystatechange = function () {
if (request.readyState === 3) { // 在 Interactive 模式就得處理
var text = request.responseText;
node.append("" + text.substring(pos) + "
"); // 用前一次的輸出長度擷取最新的字串
pos = text.length; // 更新總長度
}
};
request.open("GET", "comet-ajax.php", true); // 傳統的作法,但因 PHP 的特殊處理讓它不會中斷
request.send(null);
這個作法有個致命的缺點,就是 IE 沒辦法像 Firefox 或 Chrome 針對 readyState = 3 的資料來做處理。所以... 這個作法的可用性並不高。
2-2 用 Iframe 實作 Comet
範例程式Iframe 是過去 Comet 中最常見的作法,Server 端的程式幾乎一模一樣,只有輸出的格式改為 HTML、用來輸出一行行的 Inline JavaScript 。由於它一輸出就會執行,就沒有剛剛 XMLHttpRequest 得用 substring 取得最新資料的鳥問題了。重點是每個瀏覽器都可以用,實作起來也相當方便。而此作法的缺點為缺少像是XMLHttpRequest 可利用 readyState 判斷進度、以及 status 判斷連線狀態。
PHP 的部份
echo str_repeat(" ", 1024);
while (TRUE)
{
flush();
$num = rand(10, 100);
echo "[script]top.onmessage('Server said $num. ');[/script]";
sleep(3);
}
JavaScript 的部份
YUI().use("node-base", function (Y) {
var node = Y.one("#show"),
frame = Y.one("iframe");
window.callback = function (str) { // 設定 Iframe 的 Callback 方法
node.append(str);
};
Y.later(10, null, function () { // 只是為了早一點讓 iframe 載入,直接寫 src 太久了
document.getElementsByTagName("iframe")[0].src = "comet-iframe.php";
});
});
Iframe 解決了跨瀏覽器的問題,但所有問題解決了嗎?其實並沒有... 因為 Comet 的這種作法會把將傳統的 Web 伺服器(例如 Apache)的連線給佔住,一個人可能會有多個連線(多個 Tab)、而連線一達到上限卻又沒辦法釋放時,你的網站也就沒辦給更多人使用(IO 被佔滿)。所以 Comet 的技術得配合 Non-Blocking IO 的 Web 伺服器才能運作。另外它也只能由 Server 單方向的供給資料,比起 Polling 每次都可以互動,似乎也是一個麻煩的缺點。像是持續地檢查 Cookie 就沒辦用 Comet 來做、 Polling 才有可能。
3. 改良式 Comet - 長時間的輪詢
範例程式長時間的輪詢(Long Polling)是 Comet 演化過後的方式、也是目前 Facebook、Plurk 實現動態更新的方法。前面的 Iframe 與 XMLHttpRequest 都屬於「永遠不會斷線」的作法。Long Polling 的作法是發一個長時間等待的 Request、當伺服器有資料 Response 時立刻斷掉、接著再發一個新的 Request。
JavaScript 的部份
其實若 Server 沒有支援,它就是一個 Polling 的程式碼(一個結束後再做一個):
YUI().use("jsonp", "node-base", function (Y) {
var handler = function (response) {
Y.one("#show").append("[p]" + response.result + "[/p]");
Y.jsonp("http://comet.josephj.com/?callback={callback}", arguments.callee);
};
Y.jsonp("http://comet.josephj.com/?callback={callback}", handler);
});
nodeJS 的部份
因為 Long Polling 是目前的主流,我也用主流的 nodeJS 來寫吧。下面的 setTimeout 只是為了等待的效果,其實在實作時是可以不用對 Server 做 Polling 的,採用其他方式驅動事件才是 nodeJS 的精神。
var http = require("http"),
url = require("url"),
qs = require("querystring");
httpServer = http.createServer(function (request, response) {
var callback = qs.parse(url.parse(request.url).query).callback; // 取得 callback GET 參數
setTimeout(function () { // 3 秒後(只是為了達成等待的效果)就輸出 JSONP 格式,可以想成每一段時間就去 DB 或 memcache 查詢。
var text = callback + "({'result': 'Server said " + parseInt(new Date().getTime(), 10) + "'});";
response.write(text);
response.end(); // 結束連線。
}, 3000);
response.writeHead(200, {"Content-Type": "text/javascript"});
}).listen(1387);
與 Polling 的不同之處就在於它是比較有效率的、可以等到 timeout 或拿到資料時再重新發、因此減少不必要的流量浪費。另外,跟舊型態的 Comet 比起來,Browser 比較有機會傳遞資料(每次發新的 Request 的時候)。加上沒有瀏覽器相容性的問題,難怪它會成為當今最常見的解法了。
4. 明日之星 - WebSocket
上面所講的幾種方法,除了 Polling 外,全部都有僅單向溝通的問題。HTML5 的 WebSocket 解決了此問題。他利用新的協定建立了雙向的通道:當通道建立起來之後,Browser 可以隨時丟訊息給 Server、Server 可以隨時丟訊息給 Browser。非常地方便好用。唯一的缺點就是當今瀏覽器的支援度不普及(IE9 不支援、Chrome 支援、FF4 未知)。
JavaScript 的部份
範例程式(因為 Server 有 Proxy,所以沒辦法順利成功,但在直接連線的環境並且使用 Chrome 是沒問題的)YUI().use("node-base", function (Y) {
var node = Y.one("#show");
var conn = new WebSocket("ws://node.josephj.com/test");
conn.onopen = function (e) { // 當通道建立完畢時
Y.later(3000, null, function () { // 每三秒往 Server 塞資料
conn.send("Browser said " + parseInt(new Date().getTime()) + ".");
}, null, true);
};
conn.onmessage = function (e) { // 當收到 Server 的資料時
node.append("[p]" + e.data + "[/p]"); // 顯示在頁面上
};
});
nodeJS 的部份
範例程式因為 WebSocket 是另外一個協定,我套用了現成的 node-websocket-server 來達成。
var ws = require(__dirname + "/node-websocket-server/lib/ws/server"),
server;
server = ws.createServer(); // 建立 WebSocket 伺服器
server.addListener("connection", function (conn) { // 當與 Client 連線順利建立
conn.addListener("message", function (message) { // 當收到 Client 的連線
var text = "<" + conn.id + "> " + message + ".";
conn.send(text); // 將資料送回 Client(製造雙通道的效果)
});
setInterval(function () {
conn.send("Server said " + parseInt(new Date().getTime(), 10) + "."); // 持續的將資料送回 Client
}, 5000);
});
server.listen(1388);
寫起程式真的直覺多了,不是嗎?另外聽同事說 Socket.io 是完整解決方案,包含前後端函式庫,另外當 Browser 不支援時還有 fallback (應該是恢復使用 Long Polling)。有機會來玩 :D(註:WebSocket disabled in Firefox 4:2010/12 目前 Opera 跟 Firefox 都宣告 WebSocket 是個不安全的 Protocol、暫時無法讓開發者使用、必須修正之後再開放。我在 Chrome 9 是可以順利執行的)
結語
全部想清楚、並且都實作出來,花了我一整天的時間(特別是 WebSocket,因有架 Proxy 導致一直失敗、建議大家在試上面的所有範例時都不要有 Proxy)。唯一的好處就是搞懂 Comet 這個名詞至少代表了三種實作方法、Long Polling 則是其中的一種、也是目前最熱門的。實作的方向仍然不變囉。希望對有興趣使用的朋友有幫助。所有範例都放在 GitHub。
推薦連結
- Comet Programming: Using Ajax to Simulate Server Push: 使用 AJAX 的方式達成 Server Push。
- Comet Programming: the Hidden IFrame Technique:使用隱藏 Iframe 技巧達成 Comet。
- Introducing Socket IO:介紹 Socket IO,為了處理即時 App、跨瀏覽器的 WebSocket 解決方案。
- Comet with node.js and V8:噗浪 amix 講 node.js 與 Comet 的實作。