Browser 與 Server 持續同步的作法介紹 (Polling, Comet, Long Polling, WebSocket)

此篇文章最近更新時間為2011-03-10 21:20:17 目前共有20篇留言

關於作者 - JosephJ

任職於 Faria。喜好戶外運動、2008 年 5 月完成「跑步環島」。對於新技術跟程式碼有著強烈的偏執狂。

對 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。
});
polling

PHP 的部份

範例只是輸出亂數,你可以想像這是從 memcache 或資料庫中取出了幾筆資料:

$num = rand(10, 100); 
echo "Server said $num.\r\n";
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.\r\n";
    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);
comet-ajax

這個作法有個致命的缺點,就是 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.callback('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";
    });
});
comet-iframe

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);
long-polling

與 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);
WebSocket

寫起程式真的直覺多了,不是嗎?另外聽同事說 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

推薦連結



Comments

  1. MichaelHsu 2014-07-07 09:49:24
    謝謝你的分享!
  2. Louie 2014-07-02 09:26:23
    謝謝,相當實用的分享,也終於對即時同步的實作有更深一步的了解。
  3. aeifkz 2012-09-15 17:01:17
    好文章...先推一個在開始研究XD
  4. 神马大 2012-09-14 01:24:47
    非常感谢无私的分享!
    另外对于yoyo 2011-05-13 11:58:49提出的这个问题,我也想知道是否有更好的解决办法。对于PHP这种脚本语言
    如果说 A发起链接,没有新消息, 服务器将进程挂起sleep,在超时返回之前,这时B给A发消息,服务器上B的进程如何通知A的进程,从而使A的进程返回消息到A的客户端?
    我想到的方法只是,B把消息队列放在application之类的公共区域,服务器在挂起A的进程后,不停的循环刷新获取内存中该公共区域中的消息队列,有新消息则返回给A。求教是否有更好的方式?
  5. 小沙米 2012-09-07 17:21:16
    很感谢你的这篇文章,终于让我对Comet有了一个清楚的认识。谢谢。
  6. 拿鐵 2012-08-26 02:37:32
    不好意思,剛在重新看過文章,
    才發現您有提及目前FB是採用改良式 Comet方式實作,我真是神經大條,抱歉!

    不過問題又來了,我沒接觸過 YUI框架 和 nodeJS,只會 jQuery 語法應用...
    所以現在仍不知如何著手... @@
    再請前輩抽空賜教!感謝!
  7. 拿鐵 2012-08-26 02:26:21
    首先,很高興看到大大分享這篇精闢的文章,讓我受益匪淺!!

    可否順便請教在 facebook 左上方會以紅底白字的數字顯示資訊的則數 (收到幾則訊息那個),是用哪一種方式作的呢???

    因為我朋友的網站的站內信功能也想作出這樣的通知功能(以數字顯示收到幾則站內信),
    本來我想要用 AJAX Comet 來實作,但看到您提到它致命的缺點:
     『 IE 沒辦法 針對 readyState = 3 的資料來做處理... 』

    所以我才想如此進一步詢問,了解一下該採用哪種方法比較適當..
    煩請前輩釋疑,先在此言謝!~
  8. luke 2012-08-06 00:59:06
    comet 在遇到 某些 gateway/firewall 或是入侵偵測系統 會出問題, 可參考 XMPP Over BOSH,而websocket 看起來還需要時間
  9. Victor 2012-07-31 21:59:55
    個人最近推出一項新的雲端服務 http://ezcomet.com
    能讓 comet 的程式變得非常簡單
    有興趣可以試試看 :P
  10. yoyo 2011-05-13 11:58:49
    如果说 A发起链接,没有新消息, 服务器将进程挂起sleep,在超时返回之前,这时B给A发消息,服务器上B的进程如何通知A的进程,从而使A的进程返回消息到A的客户端?
    我想到的方法只是,B把消息队列放在application之类的公共区域,服务器在挂起A的进程后,不停的循环刷新获取内存中该公共区域中的消息队列,有新消息则返回给A。求教是否有更好的方式?
  11. xing 2011-05-07 17:01:57
    Mr. aladdin:

    Are you now still sure about your remark issued 2 months ago ??
  12. clonn 2011-03-17 10:57:48
    @aladdin 感謝分享資料。
    目前所有的實做都只為了一個目的『即時回應』,而目前還沒有任何定案(也沒有一定的標準),websocket在w3c裡面的定義是這樣子的:

    This specification defines an API that enables Web pages to use the WebSocket protocol for two-way communication with a remote host.

    用long polling 來做替代,可能比較不適當,實際上在此定義為兩端host均能正常溝通,當然就會有潛在的危險性(這就要靠有智慧的大大下去解決了)。

    當然flash socket也是一個解決方案,這個時候又要牽扯到flash與client的愛恨情仇,感覺會更為複雜!

    如果瀏覽器本身就有支援兩地端的通訊,不管什麼技術,用原生的總是比較方便:-D

    感謝Awoo大大分享以上名詞解釋~
  13. aladdin 2011-03-16 11:30:32
    websocket這個東西,在很長的時間內不會實現。

    1. browser要支援很容易。Client socket的程式碼很好寫。
    2. websocket protocol還在draft階段,但這還不是最主要的因素。
    3. websocket protocol的文件,沒有解決很多疑問。我覺得一個重要的因素會是:

    Based on the expert recommendation of the IANA, the WebSocket
    protocol by default uses port 80 for regular WebSocket connections
    and port 443 for WebSocket connections tunneled over TLS.

    這個的overhead,相當於用http做VPN。有用過這類產品的網管,應該會大叫:那你還是給我回去用long polling!為什麼IANA會這樣建議,當然是有技術的原因的。

    所以,對我來說:

    WEBSOCKET是廢柴一根。句號。

    請大家持續愛用Flash socket。
  14. Jackson 2011-03-15 10:03:36
    Joseph大大,拜读了。
  15. Awoo 2011-03-14 15:31:28
    感謝分享 :D

    整體看下來
    應是 IE 走 Comet Iframe、其他瀏覽器走 Comet AJAX 的方式解決相容性的問題
    IE 不支援 readyState = 3 的問題還是存在

    htmlfile 跟 Add Noise 的用法蠻妙的,有空來研究。
  16. tml 2011-03-14 14:04:30
    這個作法有個致命的缺點,就是 IE 沒辦法像 Firefox 或 Chrome 針對 readyState = 3 的資料來做處理。所以... 這個作法的可用性並不高。

    <---這個似乎是有解的 ? 使用 htmlfile 這個ie的activeX object來調用iframe

    參考 http://cnodejs.org/blog/?p=112

    ps.ff4好像預設是把websocket給關掉,大概都很怕安全性的問題。@@"
  17. Awoo 2011-03-14 12:46:05
    湯姆,

    感謝 :D 搞懂名詞真的很重要~

    不過這些技術 80% 是依靠後端
    但是牽扯到 nodeJS,80% 就是依靠 JavaScript
    JavaScript 歸誰哩?命苦的前端工程師啊 XDD
  18. Awoo 2011-03-14 12:43:53
    hugo:

    行李的問題,你可以參考這一篇部落格喔,「跑步環島 HOWTO」:
    http://josephjiang.com/entry.php?id=95
    原則就是補給品、與跑步過程中用不到的東西,兩天宅急便一次。
    衣物只有身上穿的跟換洗的兩套慢跑服而已。

    跑步訓練是越多越好啦,印象沒錯的話我三月四月跑了兩個馬拉松
    出發前二個禮拜,每天早上會跑個 10 K
    但是我的量不太夠,跑到台南新市左大腿靠近膝蓋的地方就肌肉發炎了
  19. hugo 2011-03-14 08:58:05
    你好!
    我要在今年七月開始完成我的跑步環島夢
    我想詢問的是,個人行李的問題,你當時是如何解決的
    另外就是之前,需要多少的跑步訓練,來準備
  20. 湯姆 2011-03-13 16:15:39
    嗯,我也一直對這些技術不太熟悉,awoo這篇文章整理的很好,很容易了解。
暱稱: 必填。
Email: 非必填。若填寫為不公開欄位,僅供站長參考聯繫。
內容: 必填。限 255 個字元以內。
驗證碼:
送出

Facebook Comment