以中介軟體,路由,跨程式事件的姿勢使用WebSocket

若邪發表於2018-11-05

通過參考koa中介軟體,socket.io遠端事件呼叫,以一種新的姿勢來使用WebSocket。

瀏覽器端

瀏覽器端使用WebSocket很簡單

// Create WebSocket connection.
const socket = new WebSocket('ws://localhost:8080');

// Connection opened
socket.addEventListener('open', function (event) {
    socket.send('Hello Server!');
});

// Listen for messages
socket.addEventListener('message', function (event) {
    console.log('Message from server ', event.data);
});
複製程式碼

MDN關於WebSocket的介紹

能註冊的事件有onclose,onerror,onmessage,onopen。用的比較多的是onmessage,從伺服器接受到資料後,會觸發message事件。通過註冊相應的事件處理函式,可以根據後端推送的資料做相應的操作。

如果只是寫個demo,單單輸出後端推送的資訊,如下使用即可:

socket.addEventListener('message', function (event) {
    console.log('Message from server ', event.data);
});
複製程式碼

實際使用過程中,我們需要判斷後端推送的資料然後執行相應的操作。比如聊天室應用中,需要判斷訊息是廣播的還是私聊的或者群聊的,以及是純文字資訊還是圖片等多媒體資訊。這時message處理函式裡可能就是一堆的if else。那麼有沒有什麼別的優雅的姿勢呢?答案就是中介軟體與事件,跨程式的事件的釋出與訂閱。在說遠端事件釋出訂閱之前,需要先從中介軟體開始,因為後面實現的遠端事件釋出訂閱是基於中介軟體的。

中介軟體

前面說了,在WebSocket例項上可以註冊事件有onclose,onerror,onmessage,onopen。每一個事件的處理函式裡可能需要做各種判斷,特別是message事件。參考koa,可以將事件處理函式以中介軟體方式來進行使用,將不同的操作邏輯分發到不同的中介軟體中,比如聊天室應用中,聊天資訊與系統資訊(比如使用者登入屬於系統資訊)是可以放到不同的中介軟體中處理的。

koa提供use介面來註冊中介軟體。我們針對不同的事件提供相應的中介軟體註冊介面,並且對原生的WebSocket做封裝。

export default class EasySocket{
    constructor(config) {
       this.url = config.url;
       this.openMiddleware = [];
       this.closeMiddleware = [];
       this.messageMiddleware = [];
       this.errorMiddleware = [];
       
       this.openFn = Promise.resolve();
       this.closeFn = Promise.resolve();
       this.messageFn = Promise.resolve();
       this.errorFn = Promise.resolve();
    }
    openUse(fn) {
        this.openMiddleware.push(fn);
        return this;
    }
    closeUse(fn) {
        this.closeMiddleware.push(fn);
        return this;
    }
    messageUse(fn) {
        this.messageMiddleware.push(fn);
        return this;
    }
    errorUse(fn) {
        this.errorMiddleware.push(fn);
        return this;
    }
}
複製程式碼

通過xxxUse註冊相應的中介軟體。 xxxMiddleware中就是相應的中介軟體。xxxFn 中介軟體通過compose處理後的結構

再新增一個connect方法,處理相應的中介軟體並且例項化原生WebSocket

connect(url) {
        this.url = url || this.url;
        if (!this.url) {
            throw new Error('url is required!');
        }
        try {
            this.socket = new WebSocket(this.url, 'echo-protocol');
        } catch (e) {
            throw e;
        }

        this.openFn = compose(this.openMiddleware);
        this.socket.addEventListener('open', (event) => {
            let context = { client: this, event };
            this.openFn(context).catch(error => { console.log(error) });
        });

        this.closeFn = compose(this.closeMiddleware);
        this.socket.addEventListener('close', (event) => {
            let context = { client: this, event };
            this.closeFn(context).then(() => {
            }).catch(error => {
                console.log(error)
            });
        });

        this.messageFn = compose(this.messageMiddleware);
        this.socket.addEventListener('message', (event) => {
            let res;
            try {
                res = JSON.parse(event.data);
            } catch (error) {
                res = event.data;
            }
            let context = { client: this, event, res };
            this.messageFn(context).then(() => {

            }).catch(error => {
                console.log(error)
            });
        });

        this.errorFn = compose(this.errorMiddleware);
        this.socket.addEventListener('error', (event) => {
            let context = { client: this, event };
            this.errorFn(context).then(() => {

            }).catch(error => {
                console.log(error)
            });
        });
        return this;
    }
複製程式碼

使用koa-compose模組處理中介軟體。注意context傳入了哪些東西,後續定義中介軟體的時候都可以使用。

compose的作用可看這篇文章 傻瓜式解讀koa中介軟體處理模組koa-compose

然後就可以使用了:

new EasySocket()
  .openUse((context, next) => {
    console.log("open");
    next();
  })
  .closeUse((context, next) => {
    console.log("close");
    next();
  })
  .errorUse((context, next) => {
    console.log("error", context.event);
    next();
  })
  .messageUse((context, next) => {
    //使用者登入處理中介軟體
    if (context.res.action === 'userEnter') {
      console.log(context.res.user.name+' 進入聊天室');
    }
    next();
  })
  .messageUse((context, next) => {
    //建立房間處理中介軟體
    if (context.res.action === 'createRoom') {
      console.log('建立房間 '+context.res.room.anme);
    }
    next();
  })
  .connect('ws://localhost:8080')
複製程式碼

可以看到,使用者登入與建立房間的邏輯放到兩個中介軟體中分開處理。不足之處就是每個中介軟體都要判斷context.res.action,而這個context.res就是後端返回的資料。怎麼消除這個頻繁的if判斷呢? 我們實現一個簡單的訊息處理路由。

路由

定義訊息路由中介軟體

messageRouteMiddleware.js

export default (routes) => {
    return async (context, next) => {
        if (routes[context.res.action]) {
            await routes[context.res.action](context,next);
        } else {
            console.log(context.res)
            next();
        }
    }
}

複製程式碼

定義路由

router.js

export default {
    userEnter:function(context,next){
        console.log(context.res.user.name+' 進入聊天室');
        next();
    },
    createRoom:function(context,next){
        console.log('建立房間 '+context.res.room.anme);
        next();
    }
}
複製程式碼

使用:

new EasySocket()
  .openUse((context, next) => {
    console.log("open");
    next();
  })
  .closeUse((context, next) => {
    console.log("close");
    next();
  })
  .errorUse((context, next) => {
    console.log("error", context.event);
    next();
  })
  .messageUse(messageRouteMiddleware(router))//使用訊息路由中介軟體,並傳入定義好的路由
  .connect('ws://localhost:8080')
複製程式碼

一切都變得美好了,感覺就像在使用koa。想一個問題,當接收到後端推送的訊息時,我們需要做相應的DOM操作。比如路由裡面定義的userEnter,我們可能需要在對應的函式裡操作使用者列表的DOM,追加新使用者。這使用原生JS或JQ都是沒有問題的,但是如果使用vue,react這些,因為是元件化的,使用者列表可能就是一個元件,怎麼訪問到這個元件例項呢?(當然也可以訪問vuex,redux的store,但是並不是所有元件的資料都是用store管理的)。

我們需要一個執行時註冊中介軟體的功能,然後在元件的相應的生命週期鉤子裡註冊中介軟體並且傳入元件例項

執行時註冊中介軟體,修改如下程式碼:

messageUse(fn, runtime) {
        this.messageMiddleware.push(fn);
        if (runtime) {
            this.messageFn = compose(this.messageMiddleware);
        }
        return this;
    }
複製程式碼

修改 messageRouteMiddleware.js

export default (routes,component) => {
    return async (context, next) => {
        if (routes[context.req.action]) {
            context.component=component;//將元件例項掛到context下
            await routes[context.req.action](context,next);
        } else {
            console.log(context.req)
            next();
        }
    }
}

複製程式碼

類似vue mounted中使用

mounted(){
  let client = this.$wsClients.get("im");//獲取指定EasySocket例項
  client.messageUse(messageRouteMiddleware(router,this),true)//執行時註冊中介軟體,並傳入定義好的路由以及當前元件中的this
}
複製程式碼

路由中通過 context.component 即可訪問到當前元件。

完美了嗎?每次元件mounted 都註冊一次中介軟體,問題很大。所以需要一個判斷中介軟體是否已經註冊的功能。也就是一個支援具名註冊中介軟體的功能。這裡就暫時不實現了,走另外一條路,也就是之前說到的遠端事件的釋出與訂閱,我們也可以稱之為跨程式事件。

跨程式事件

看一段socket.io的程式碼:

Server (app.js)

var app = require('http').createServer(handler)
var io = require('socket.io')(app);
var fs = require('fs');
app.listen(80);
function handler (req, res) {
  fs.readFile(__dirname + '/index.html',
  function (err, data) {
    if (err) {
      res.writeHead(500);
      return res.end('Error loading index.html');
    }

    res.writeHead(200);
    res.end(data);
  });
}
io.on('connection', function (socket) {
  socket.emit('news', { hello: 'world' });
  socket.on('my other event', function (data) {
    console.log(data);
  });
});
複製程式碼

Client (index.html)

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

注意力轉到這兩部分:

服務端

  socket.emit('news', { hello: 'world' });
  socket.on('my other event', function (data) {
    console.log(data);
  });
複製程式碼

客戶端

  var socket = io('http://localhost');
  socket.on('news', function (data) {
    console.log(data);
    socket.emit('my other event', { my: 'data' });
  });
複製程式碼

使用事件,客戶端通過on訂閱'news'事件,並且當觸發‘new’事件的時候通過emit釋出'my other event'事件。服務端在使用者連線的時候釋出'news'事件,並且訂閱'my other event'事件。

一般我們使用事件的時候,都是在同一個頁面中on和emit。而socket.io的神奇之處就是同一事件的on和emit是分別在客戶端和服務端,這就是跨程式的事件。

那麼,在某一端emit某個事件的時候,另一端如果on監聽了此事件,是如何知道這個事件emit(釋出)了呢?

沒有看socket.io原始碼之前,我設想應該是emit方法裡做了某些事情。就像java或c#,實現rpc的時候,可以依據介面定義動態生成實現(也稱為代理),動態實現的(代理)方法中,就會將當前方法名稱以及引數通過相應協議進行序列化,然後通過http或者tcp等網路協議傳輸到RPC服務端,服務端進行反序列化,通過反射等技術呼叫本地實現,並返回執行結果給客戶端。客戶端拿到結果後,整個呼叫完成,就像呼叫本地方法一樣實現了遠端方法的呼叫。

看了socket.io emit的程式碼實現後,思路也是大同小異,通過將當前emit的事件名和引數按一定規則組合成資料,然後將資料通過WebSocket的send方法傳送出去。接收端按規則取到事件名和引數,然後本地觸發emit。(注意遠端emit和本地emit,socket.io中直接呼叫的是遠端emit)。

下面是實現程式碼,事件直接用的emitter模組,並且為了能自定義emit事件名和引數組合規則,以中介軟體的方式提供處理方法:

export default class EasySocket extends Emitter{//繼承Emitter
    constructor(config) {
       this.url = config.url;
       this.openMiddleware = [];
       this.closeMiddleware = [];
       this.messageMiddleware = [];
       this.errorMiddleware = [];
       this.remoteEmitMiddleware = [];//新增的部分
       
       this.openFn = Promise.resolve();
       this.closeFn = Promise.resolve();
       this.messageFn = Promise.resolve();
       this.errorFn = Promise.resolve();
       this.remoteEmitFn = Promise.resolve();//新增的部分
    }
    openUse(fn) {
        this.openMiddleware.push(fn);
        return this;
    }
    closeUse(fn) {
        this.closeMiddleware.push(fn);
        return this;
    }
    messageUse(fn) {
        this.messageMiddleware.push(fn);
        return this;
    }
    errorUse(fn) {
        this.errorMiddleware.push(fn);
        return this;
    }
    //新增的部分
    remoteEmitUse(fn, runtime) {
        this.remoteEmitMiddleware.push(fn);
        if (runtime) {
            this.remoteEmitFn = compose(this.remoteEmitMiddleware);
        }
        return this;
    }
    connect(url) {
       ...
       //新增部分
       this.remoteEmitFn = compose(this.remoteEmitMiddleware);
    }
    //重寫emit方法,支援本地呼叫以遠端呼叫
    emit(event, args, isLocal = false) {
        let arr = [event, args];
        if (isLocal) {
            super.emit.apply(this, arr);
            return this;
        }
        let evt = {
            event: event,
            args: args
        }
        let remoteEmitContext = { client: this, event: evt };
        this.remoteEmitFn(remoteEmitContext).catch(error => { console.log(error) })
        return this;
    }
}
複製程式碼

下面是一個簡單的處理中介軟體:

client.remoteEmitUse((context, next) => {
    let client = context.client;
    let event = context.event;
    if (client.socket.readyState !== 1) {
      alert("連線已斷開!");
    } else {
      client.socket.send(JSON.stringify({
        type: 'event',
        event: event.event,
        args: event.args
      }));
      next();
    }
  })
複製程式碼

意味著呼叫

client.emit('chatMessage',{
    from:'admin',
    masg:"Hello WebSocket"
});
複製程式碼

就會組合成資料

{
    type: 'event',
    event: 'chatMessage',
    args: {
        from:'admin',
        masg:"Hello WebSocket"
    }
}
複製程式碼

傳送出去。

服務端接受到這樣的資料,可以做相應的資料處理(後面會使用nodejs實現類似的程式設計模式),也可以直接傳送給別的客戶端。客戶受到類似的資料,可以寫專門的中介軟體進行處理,比如:

client.messageUse((context, next) => {
    if (context.res.type === 'event') {
      context.client.emit(context.res.event, context.res.args, true);//注意這裡的emit是本地emit。
    }
    next();
})
複製程式碼

如果本地訂閱的chatMessage事件,回到函式就會被觸發。

在vue或react中使用,也會比之前使用路由的方式簡單

mounted() {
   let client = this.$wsClients.get("im");
   client.on("chatMessage", data => {
      let isSelf = data.from.id == this.user.id;
      let msg = {
        name: data.from.name,
        msg: data.msg,
        createdDate: data.createdDate,
        isSelf
      };
      this.broadcastMessageList.push(msg);
    });
}

複製程式碼

元件銷燬的時候移除相應的事件訂閱即可,或者清空所有事件訂閱

destroyed() {
    let client = this.$wsClients.get("im");
    client.removeAllListeners();
}

複製程式碼

心跳重連

核心程式碼直接從websocket-heartbeat-js copy過來的(用npm包,還得在它的基礎上再包一層),相關文章 初探和實現websocket心跳重連

核心程式碼:

    heartCheck() {
        this.heartReset();
        this.heartStart();
    }
    heartStart() {
        this.pingTimeoutId = setTimeout(() => {
            //這裡傳送一個心跳,後端收到後,返回一個心跳訊息
            this.socket.send(this.pingMsg);
            //接收到心跳資訊說明連線正常,會執行heartCheck(),重置心跳(清除下面定時器)
            this.pongTimeoutId = setTimeout(() => {
                //此定時器有執行的機會,說明傳送ping後,設定的超時時間內未收到返回資訊
                this.socket.close();//不直接呼叫reconnect,避免舊WebSocket例項沒有真正關閉,導致不可預料的問題
            }, this.pongTimeout);
        }, this.pingTimeout);
    }
    heartReset() {
        clearTimeout(this.pingTimeoutId);
        clearTimeout(this.pongTimeoutId);
    }
複製程式碼

最後

原始碼地址:easy-socket-browser

nodejs實現的類似的程式設計模式(有空再細說):easy-socket-node

實現的聊天室例子:online chat demo

聊天室前端原始碼:lazy-mock-im

聊天室服務端原始碼:lazy-mock

相關文章