WebSocket API
市面上其实非常多 Socket.io 的文章,所以我写在这里其实是笔记居多,不嫌弃的话可以继续看下去这样。
WebSocket API
这一项技术其实在 w3c 上面还是 Draft 的状态,所以,其实你会听到大部分的人会说,用 Flash 来作会比较稳定一点。而其实 Socket.io 官方 wiki 上面也有提到 FlashScoket.IO 的东西(笑
这个东西是 HTML5 的新的协定,简单的来说,就是可以让浏览器与后端伺服器之间,经由一个握手(handshake)的动作,来连接一条两者之间的高速公路。这幺一来,我们就可以浏览器与后端伺服器之间,快速的传递一些资料。
维基百科上的 WebSocket 介绍。<http://zh.wikipedia.org/wiki/WebSocket>
其中有提到了目前的方式,大多是以轮巡(Polling)的方式来达成,还有一种是 Comet(我实在不知道该怎幺用中文来描述他),而因为 Comet 会在后端伺服器上面佔用连线,且若是非 non-blocking 的伺服器,像是 Apache,很容易会让 IO 爆炸。所以,后来就出现了长轮巡(Long Polling)与 iframe 改良式的 Comet。
以上的作法大多都以 AJAX(XHR)来去实做,而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 Push<http://www.webreference.com/programming/javascript/rg28/index.html>
- Comet Programming: the Hidden IFrame Technique<http://www.webreference.com/programming/javascript/rg30/index.html>
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-polling 与 jsonp-polling 两种方式的其中一种,来传递我们的资料。
更多详细设定,在官方的 wiki 当中有相当详细的说明,
- Configuring Socket.IO<https://github.com/LearnBoost/Socket.IO/wiki/Configuring-Socket.IO>
至于 Socket.IO 在握手(handshake)的处理的部份,在官方 wiki 也有说明,
- Authorization and handshaking<https://github.com/LearnBoost/socket.io/wiki/Authorizing>
为什幺要作上述的动作呢?顾名思义就是为了认证的一些流程而衍生出来的需求。我可以在这个过程中查询 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; } } }