[NodeJS] Websocket 的強力工具 Socket.io

市面上其實非常多 Socket.io 的文章,所以我寫在這裡其實是筆記居多,不嫌棄的話可以繼續看下去這樣。

WebSocket API

這一項技術其實在 w3c 上面還是 Draft 的狀態,所以,其實你會聽到大部分的人會說,用 Flash 來作會比較穩定一點。而其實 Socket.io 官方 wiki 上面也有提到 FlashScoket.IO 的東西(笑

這個東西是 HTML5 的新的協定,簡單的來說,就是可以讓瀏覽器與後端伺服器之間,經由一個握手(handshake)的動作,來連接一條兩者之間的高速公路。這麼一來,我們就可以瀏覽器與後端伺服器之間,快速的傳遞一些資料。

維基百科上的 WebSocket 介紹。

其中有提到了目前的方式,大多是以輪巡(Polling)的方式來達成,還有一種是 Comet(我實在不知道該怎麼用中文來描述他),而因為 Comet 會在後端伺服器上面佔用連線,且若是非 non-blocking 的伺服器,像是 Apache,很容易會讓 IO 爆炸。所以,後來就出現了長輪巡(Long Polling)與 iframe 改良式的 Comet。

以上的作法大多都以 AJAX(XHR)來去實做(另外我也不懂,以前玩到很膩的 XHR 為什麼後來要叫做 AJAX

而,WebSocket 就解決了許多的問題(至於有哪些問題,不要問,很恐怖

而且他是可以雙向溝通的!

溝通的問題

目前其實最流行的方式,還是以 Long Polling 為主,最重要的原因是沒有瀏覽器相容性的問題

BUT!

如果你的後端伺服器不支援的話,那他就只是一個單純的 Polling 而已。為什麼?

(function polling() {
    $.ajax({
        url: "http://server",
        type: "post",
        dataType: "json",
        timeout: 30000,
        success: function(data) {
            /* Do something */
        },
        complete: function() {
            /* Polling here. */
            polling();
        }
    });
})();

上面我做了一件事情,就是等待 30 秒後重複發送一個 ajax 的請求到後端伺服器去。而 Long Polling 的作法是,

  • 前端送了一個請求給後端
  • 後端收到後,回傳資料給前端,並斷開連線
  • 前端收到後,執行 callback,並再次發送一個請求給後端

以上的方式就是一個無窮迴圈,所以,如果後端收到後,沒有斷開連線,那麼前端就只會每 30 秒斷線重連,這樣跟一般的 Polling 其實並沒有兩樣。那,為什麼非 non-blocking 的後端伺服器不行?如果我送一個 ajax 給 Apache,那他把事情做完之後,丟一個回應給前端,也會達成 complete 的條件不是嗎?

是!

<?php

/* 我在 php 睡了 10 秒,再吐資料給剛剛呼叫我的 ajax */
sleep(10);
echo json_encode(array('status' => 'ok'));

但是,當你的後端伺服器沒有放開連線時,你只能等待前端 timeout 的時間到了,並且再次發送一次請求時,才能繼續動作。而,屆時後端的資料到底做完了沒呢?答案是:不知道,所以,使用 non-blocking 的後端伺服器多少能避開這些問題。

以上,都是單向的溝通,也是目前流行的方式。

這裡有兩篇 Comet 文章可以看一下:
Comet Programming: Using Ajax to Simulate Server Pus
Comet Programming: the Hidden IFrame Technique

Socket.IO

他做了一件事情,就是把那些溝通的方式全部整合起來,無論前端還是後端,他都幫你打包好。所以,你只要會用就可以了,這樣是不是很佛心呢!

$ npm install socket.io

他所支援的傳輸方式有下列幾種,

  • xhr-polling
  • xhr-multipart
  • htmlfile
  • websocket
  • flashsocket
  • jsonp-polling

除了字面上有 socket 的之外,都是 Polling 與其變種方式,其中 xhr-multipart 也是,他只是把資料拆成好幾個部份來傳送而已。而其中 htmlfile 貌似是 IE 底下的東西,我在大神上面問資料的時候,看到了 ActiveXObject 這幾個字,我就不想理他了

簡單的後端應用方式,我們可以這樣寫(以下是官方範例,

var io = require('socket.io').listen(8080);

io.sockets.on('connection', function (socket) {
    socket.emit('news', { hello: 'world' });
    socket.on('my other event', function (data) {
        console.log(data);
    });
});

而前端是這個樣子,

<script src="/socket.io/socket.io.js"></script>
<script>
    var socket = io.connect('http://localhost:8080');
    socket.on('news', function (data) {
        console.log(data);
        socket.emit('my other event', { my: 'data' });
    });
</script>

我們沒有特別去指定 Socket.IO 要用什麼方式來作傳遞,所以他會自己決定,透過目前你的瀏覽器能使用什麼方式,來傳遞我們所需要的資料。這麼說,我們也可以指定傳遞方式,

var io = require('socket.io').listen(8080);

io.configure('development', function() {
    io.set('transports', [
            'xhr-polling'
            , 'jsonp-polling'
        ]);
});

io.sockets.on('connection', function (socket) {
    socket.emit('news', { hello: 'world' });
    socket.on('my other event', function (data) {
        console.log(data);
    });
});

以上述的例子來說,他就會使用 xhr-pollingjsonp-polling 兩種方式的其中一種,來傳遞我們的資料。

更多詳細設定,在官方的 wiki 當中有相當詳細的說明,

Configuring Socket.IO

至於 Socket.IO 在握手(handshake)的處理的部份,在官方 wiki 也有說明,

Authorization and handshaking

為什麼要作上述的動作呢?顧名思義就是為了認證的一些流程而衍生出來的需求。我可以在這個過程中查詢 Session 的相關資料,也可以檢查 Cookie,IP Address 或是其他需要處理的資料等等。當然,處理 Cookie 與 Session 則最為常見。

小插曲

我們在使用 Socket.IO 的時候,當然不可能將 listen 給綁在 port 80 上面,那是給一般伺服器使用的嘛。所以,我們就有可能會像上述的例子一樣,把他綁在 port 8080 或是之類的額外的連接埠上面。

問題來了,如果綁在其他的連接埠,那麼前端的呼叫的位址就得加上埠號,否則你的動作是會失效的。怎麼解決呢?網路上有一個很玄妙的解法,利用改寫 Socket.IO 的 xhr-polling 對於 XHRPolling 與 XHRPolling 的處理方式,來讓前端不需要加上埠號就能動作,

io.configure(function() {
  io.set("transports", ["xhr-polling"]);
  io.set("polling duration", 10);

  var path = require('path');
  var HTTPPolling = require(path.join(
    path.dirname(require.resolve('socket.io')),'lib', 'transports','http-polling')
  );
  var XHRPolling = require(path.join(
    path.dirname(require.resolve('socket.io')),'lib','transports','xhr-polling')
  );

  XHRPolling.prototype.doWrite = function(data) {
    HTTPPolling.prototype.doWrite.call(this);

    var headers = {
      'Content-Type': 'text/plain; charset=UTF-8',
      'Content-Length': (data && Buffer.byteLength(data)) || 0
    };

    if (this.req.headers.origin) {
      headers['Access-Control-Allow-Origin'] = '*';
      if (this.req.headers.cookie) {
        headers['Access-Control-Allow-Credentials'] = 'true';
      }
    }

    this.response.writeHead(200, headers);
    this.response.write(data);
    this.log.debug(this.name + ' writing', data);
  };
});

有興趣的人,原文在此,請參閱:How to make Socket.IO work behind nginx (mostly)

另外補上 Nginx 的相關設定,其實並不複雜,就依照一般的 Proxy 去設定即可,

user www-data;
worker_processes 4;
worker_rlimit_nofile 1024;

pid /var/run/nginx.pid;


events {
    worker_connections 1024;
    multi_accept on;
    use epoll;
}

http {
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;

    server_names_hash_bucket_size 128;
    server_name_in_redirect on;
    client_header_buffer_size 32k;
    large_client_header_buffers 4 32k;
    client_max_body_size 8m;

    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    access_log /var/log/nginx/access.log;
    error_log /var/log/nginx/error.log;

    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_buffers 16 8k;
    gzip_http_version 1.1;
    gzip_disable "MSIE [1-6].(?!.*SV1)";
    gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;

    limit_req_zone $binary_remote_addr zone=one:10m rate=10r/s;
    limit_req zone=one burst=100 nodelay;

    upstream nodejs {
        ip_hash;
        server localhost:3000;
    }

    server {
        listen   80;
        server_name jsdc;

        root /var/www/mynode;
        index index.html index.htm;

        location / {
        proxy_set_header X-Real-IP  $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_set_header X-NginX-Proxy true;
        proxy_pass http://nodejs/;
        proxy_redirect off;
        }
    }
}

結語

我沒有講很多例子,因為官網上面都是例子。一起來參加 JSDC.tw 如果能撐到我的 Lighting Talk,我就可以讓你看看我的例子了(笑

Hina Chen
偏執與強迫症的患者,算不上是無可救藥,只是我已經遇上我的良醫了。
Taipei