Socket.io:有点意思

下面页面就是使用Socket.io制作的口袋妖怪游戏(默认小屏下已隐藏,请切换到大分辨率查看)。左边是游戏画面,右边是按键表和聊天室。画面达到红蓝版本的水平了。


  1. 前导 ——WebSocket的介绍

      传统的Web应用采用的是客户端发出请求、服务器端响应的工作方式。在这种情景下,浏览器作为Web应用的前端,自身的处理功能是十分有限的。这种方法不能满足某些应用的实时需求(服务器需要主动更新浏览器端的数据)。不同于服务器端等待HTTP请求,这需要服务器端主动发送数据以给客户端更新。解决方案有两类:一类是基于HTTP的Comet推送技术,另一类是基于套接口(Socket)传送信息实现消息传输。
      而目前使用Comet主要有两种方式,轮询和iframe流。

    • 轮询 polling
        浏览器周期性的发出请求,如果服务器没有新数据需要发送就返回以空响应。这种方法问题很大:首先,大量无意义的请求造成网络压力;其次,请求周期的限制不能及时地获得最新数据。这种方法很快就被淘汰。

    • 长轮询 long polling
        长轮询是在打开一条连接以后保持连接,等待服务器推送来数据再关闭连接。然后浏览器再发出新的请求,这能更好地管理请求数量,也能及时地更新数据。AJAX调用XMLHttpRequest对象发出HTTP请求,JS响应处理函数根据服务器返回的数据更新HTML页面的展示。这个方法一定程度上消除了简单轮询的弊端,但服务器压力也是很大。

    • iframe流 iframe streaming
        iframe流方式是在页面中插入一个隐藏的iframe,利用其src属性在服务器和客户端之间建立一条长链接,服务器向iframe传输数据(通常是HTML,内有负责插入信息的javascript),来实时更新页面。"iframe是很早就存在的一种 HTML 标记,通过在 HTML 页面里嵌入一个隐蔵帧,然后将这个隐蔵帧的 SRC属性设为对一个长连接的请求,服务器端就能源源不断地往客户端输入数据。”其不足为:进度条会显示一直,反应在页面上就是浏览器标签页的图标会不停地转动。(当然这也是有解决方法的)

      另一类方法则是基于WebSocket
      HTML5提供的Websocket不同于上面这些在老的HTML已有框架内的方法,而是在单个TCP连接上进行全双工通讯的协议。目前主流浏览器都已支持。
      1. 初始化过程
       不同于早期JAVA使用在浏览器安装插件的方法——-Java Applet 套接口:这种方法不足在于Java Applet再收到服务器返回的消息后,无法通过Javascript去更新HTML页面的内容。而是通过HTTP建立连接(HTTP handshake)。
      2. 开始通讯
       一旦初始连接建立,浏览器和服务器就打开了一个TCP socket的频道。在这个频道内就能进行双向的数据通信。

    然而Websocket依然有一些问题。比如浏览器兼容性问题(随着浏览器的发展,肯定是越来越小的),以及网络中间物(代理服务、防火墙)问题不支持WebSocket,这时Socket.io的出现就是为了完善WebSocket。

  2. Socket.IO

      Guillermo Rauch在2010年开发第一版时,目的很明确地指向Node.js实时应用。在几次版本更新后,重新定义和封装核心功能而分化出一个基础模块 Engine.io——力求建立更稳定的工具。Engine.IO有着更稳定的连接质量。使得Socket.IO在先打开一个长轮询,再在将连接推至WebSocket频道继续通信。
      在使用Node的http模块创建服务器同时还要Express应用,因为这个服务器对象需要同时充当Express服务和Socket.io服务。(如下)

    var app = require('express')(); //Express服务
    var server = require('http').Server(app); //原生Http服务
    var io = require('socket.io')(server); //Socket.io服务
    io.on('connection', function(socket){
        /* 具体操作 */
    });
    server.listen(3000);
    

      当客户端需要连接服务器时,它需要先建立一个握手。io.处理连接事件,socket 处理断开连接事件。在上面代码里,这套握手机制是完全自动的,我们可以通过也可以io.use()方法来设置这一过程。
      客户端使用js调用socket.io的Client API即可。

    <script src="/lib/socket.io/socket.io.js"></script>
    <script>
       var socket = io();
       socket.on('connect', function() {
               /* 具体操作 */
       });
    </script>
    

      Socket.IO还要一些系统事件,包括了连接、重连、关闭的事件。我们也可以自定义事件,以及监听方法。

    socket.on('customEvent', function(customEventData) {
         /* 具体操作 */
     });
    

      相应地,在对的时间和地方的调用.emit('customEvent', customEventData); 触发事件就行了。不过,事件是无法在客户端之间发送的。
      同一个服务器可以使用namespaces创造不同的Socket连接。Socket.IO使用of()来指定不同的命名空间。

    io.of('/someNamespace').on('connection', function(socket){
        socket.on('customEvent', function(customEventData) {
            /* 具体操作 */
        });
    });
    io.of('/someOtherNamespace').on('connection', function(socket){
        socket.on('customEvent', function(customEventData) {
        /* 具体操作 */
        });
    });
    

      服务器端则通过在定义Socket对象时传递namespace参数。

    <script>
     var someSocket = io('/someNamespace');
     someSocket.on('customEvent', function(customEventData) {
         /* 具体操作 */
     });
     var someOtherSocket = io('/someOtherNamespace');
     someOtherSocket.on('customEvent', function(customEventData) {
         /* 具体操作 */
     });
    </script>
    

      在每一个namespace中又可以使用room来进一步划分,不过sockets是使用join()、leave()来调用。

    //服务器端
    io.on('event', function(eventData){
         //监听join事件
         socket.on('join', function(roomData){
              socket.join(roomData.roomName);
         });
         //监听leave事件
         socket.on('leave', function(roomData){
              socket.leave(roomData.roomName);
         });
    });
    //浏览器端
    io.on('connection', function(socket){
         //在此room下触发事件
         io. in('someRoom') .emit('customEvent', customEventData);
    });
    

  下面通过《MEAN Web Development》书中的例子来实际操作一下。

  1. 配置Socket.io服务器
     首先安装安装Socket.IO、connect-mongo、cookie-parser依赖我们先将依赖报引入,然后定义服务器对象。

    var http = require('http');
    var socketio = require('socket.io');
    //...
    var app = express();
    var server = http.createServer(app);
    var io = socketio.listen(server);
    
  2. 配置Socket.io Session
     为了是Socket.io seesion 和Express session一起工作,我们必须让他们信息共享。Express Session 默认是存储在内存,我们需要把它存在mongoDB以便Socket.io能获取。使用connect-mongo来控制session信息的存储,以及使用以前用到过的cookie-parse来解析session cookie信息。
     先来修改express.js文件以便connect-mongo能够正常使用。

    var mongoStore = new MongoStore({
        db: db.connection.db //通过server.js传递参数db到express的配置中
    });
    app.use(session({
        saveUninitialized: true,
        resave: true,
        secret: config.sessionSecret,
        store: mongoStore
    }));
    

     这样Session就存到数据库中来,新建配置文件socketio.js来配置socketio

    var config = require('./config'),
        cookieParser = require('cookie-parser'),
        passport = require('passport');
    /**
     * @description
     * @param {HTTP object} server 带socket服务的http服务
     * @param {Socket.io Object} io 监听server的Socket服务
     * @param {MongoStore Object} mongoStore mongoDB的存储
     *
     * */
    module.exports = function(server, io, mongoStore){
        io.use(function(socket, next){
            //解析请求socket.request
            cookieParser(config.sessionSecret)(socket.request, {}, function(err){
                //获得sessionId
                var sessionId = socket.request.signedCookies['connect.sid'];
                //获得数据库中的session数据
                mongoStore.get(sessionId, function(err, session){
                    socket.request.session = session;
                    //填充 socket.request.user对象
                    passport.initialize()(socket.request, {}, function(){
                        passport.session()(socket.request, {}, function(){
                            if(socket.request.user){
                                next(null, true);
                            }else{
                                next(new Error('User is not authenticated'), false);
                            }
                        });
                    });
                });
            });
            io.on('connection', function(socket){
                console.log('a socket is connected');
                require('../app/controllers/chat.server.controller')(io, socket);
            });
        });
    };
    

     cookieParser首先解析Express的Session,然后读取sessionId获得数据库中的session数据,填充到user对象中。如果通过passport来验证用户数据是非法的,则跳出Socket.IO的设置,并发出错误提示。接下来只需要建立Socket.IO的后端控制器即可完成后端的开发。

  3. 配置chat控制器
     chat功能的控制器统一监听和触发Socket.IO事件来进行数据通信。通过事件处理的回调函数来控制数据格式的建立和分发。

    module.exports = function(io, socket){
        //触发chatMessage事件,提示用户已连接
        io.emit('chatMessage', {
            type: 'status',
            text: 'connected',
            created: Date.now(),
            username: socket.request.user.username
        });
        //监听chatMessage事件,获得用户的消息
        socket.on('chatMessage', function(message){
            message.type = 'message';
            message.created = Date.now();
            messsage.username = socket.request.user.username;
            //触发事件并发送数据。
            io.emit('chatMessage', message);
        });
        //监听断开连接事件
        socket.on('disconnect', function(message){
            //触发事件并发送数据。
            io.emit('chatMessage', {
                type: 'status',
                text: 'disconnected',
                created: Date.now(),
                username: socket.request.user.username
            });
        });
    };
    

     确定监听事件规则后,将控制器载入到Socket.IO的连接事件处理函数中即可。

    io.on('connection', function(socket){
        console.log('a socket is connected');
        require('../app/controllers/chat.server.controller')(io, socket);
    });
    
  4. Angular前端设计
     我们先通过建立ng-resource来封装Socket.IO的方法,再中前端的控制器中调用。
     service是懒加载,即只有在请求时才加载。这可以阻止未验证用户调用到service的方法来获得数据,将emit()、on()、removeListenter()一套方法封装成的更相容的服务方法,减少代码的重写。然而ng的数据绑定只有在框架内执行的方法才能实时改变,也就是说第三方事件导致的数据模型的改变是未知的。那么,我们在socket中任何事件被触发时,处理函数对数据的修改可能不会及时地绑定到$scope数据模型上。(这都是抄来的)这里使用$timeout来强制完成数据的绑定。

    angular.module('chat').service('Socket', ['Authentication', '$location', '$timeout', function(Authentication, $location, $timeout){
        //首先确认用户
        if(Authentication.user){
            this.socket = io();
        }else{
            $location.path('/');
        }
        //通用监听方法
        this.on = function(eventName, callback){
            if(this.socket){
                this.socket.on(eventName, function(data){
                    $timeout(function(){
                        callback(data);
                    });
                });
            }
        };
        //通用触发方法
        this.emit = function(eventName, data){
            if(this.socket){
                this.socket.emit(eventName, data);
            }
        };
        //通用删除监听器的方法
        this.removeListener = function(eventName){
            if(this.socket){
                this.socket.removeListener(eventName);
            }
        };
    }]);
    

     接着在前端控制器中调用这些方法来处理后端触发的事件和触发后端能处理的事件。

    //监听后端发送的chatMessage事件
    Socket.on('chatMessage', function(message){
        $scope.messages.push(message);
    });
    //监听后端发送的chatMessage事件
    $scope.sendMessage = function(){
        var message = {
            text: this.messageText
        };
        //监听后端发送的chatMessage事件
        Socket.emit('chatMessage', message);
        //及时清空ng-model
        this.messageText = '';
    };
    //监听$destroy,当controller实例被摧毁删除 监听器
    $scope.$on('$destroy', function(){
        Socket.removeListener('chatMessage');
    });
    

     将ng引入到对应的视图模板,测试一下即可。


以上就是Socket.IO的上手实战。先了解Socket.IO的工作机制,再将整个数据通信的流程走了一遍,在实践上将Socket.IO与Express、Passport整合到一起完成了Web聊天室的功能,也见识到了Node.JS的小组件大组合的哲学。


References:

  1. 推送技术
  2. 小程源码