通过参考koa中间件,socket.io远程事件调用,以一种新的姿势来使用WebSocket。

浏览器端

浏览器端使用WebSocket很简单

  1. // Create WebSocket connection.
  2. const socket = new WebSocket('ws://localhost:8080');
  3. // Connection opened
  4. socket.addEventListener('open', function (event) {
  5. socket.send('Hello Server!');
  6. });
  7. // Listen for messages
  8. socket.addEventListener('message', function (event) {
  9. console.log('Message from server ', event.data);
  10. });

MDN关于WebSocket的介绍

能注册的事件有onclose,onerror,onmessage,onopen。用的比较多的是onmessage,从服务器接受到数据后,会触发message事件。通过注册相应的事件处理函数,可以根据后端推送的数据做相应的操作。

如果只是写个demo,单单输出后端推送的信息,如下使用即可:

  1. socket.addEventListener('message', function (event) {
  2. console.log('Message from server ', event.data);
  3. });

实际使用过程中,我们需要判断后端推送的数据然后执行相应的操作。比如聊天室应用中,需要判断消息是广播的还是私聊的或者群聊的,以及是纯文字信息还是图片等多媒体信息。这时message处理函数里可能就是一堆的if else。那么有没有什么别的优雅的姿势呢?答案就是中间件与事件,跨进程的事件的发布与订阅。在说远程事件发布订阅之前,需要先从中间件开始,因为后面实现的远程事件发布订阅是基于中间件的。

中间件

前面说了,在WebSocket实例上可以注册事件有onclose,onerror,onmessage,onopen。每一个事件的处理函数里可能需要做各种判断,特别是message事件。参考koa,可以将事件处理函数以中间件方式来进行使用,将不同的操作逻辑分发到不同的中间件中,比如聊天室应用中,聊天信息与系统信息(比如用户登录属于系统信息)是可以放到不同的中间件中处理的。

koa提供use接口来注册中间件。我们针对不同的事件提供相应的中间件注册接口,并且对原生的WebSocket做封装。

  1. export default class EasySocket{
  2. constructor(config) {
  3. this.url = config.url;
  4. this.openMiddleware = [];
  5. this.closeMiddleware = [];
  6. this.messageMiddleware = [];
  7. this.errorMiddleware = [];
  8. this.openFn = Promise.resolve();
  9. this.closeFn = Promise.resolve();
  10. this.messageFn = Promise.resolve();
  11. this.errorFn = Promise.resolve();
  12. }
  13. openUse(fn) {
  14. this.openMiddleware.push(fn);
  15. return this;
  16. }
  17. closeUse(fn) {
  18. this.closeMiddleware.push(fn);
  19. return this;
  20. }
  21. messageUse(fn) {
  22. this.messageMiddleware.push(fn);
  23. return this;
  24. }
  25. errorUse(fn) {
  26. this.errorMiddleware.push(fn);
  27. return this;
  28. }
  29. }

通过xxxUse注册相应的中间件。 xxxMiddleware中就是相应的中间件。xxxFn 中间件通过compose处理后的结构

再添加一个connect方法,处理相应的中间件并且实例化原生WebSocket

  1. connect(url) {
  2. this.url = url || this.url;
  3. if (!this.url) {
  4. throw new Error('url is required!');
  5. }
  6. try {
  7. this.socket = new WebSocket(this.url, 'echo-protocol');
  8. } catch (e) {
  9. throw e;
  10. }
  11. this.openFn = compose(this.openMiddleware);
  12. this.socket.addEventListener('open', (event) => {
  13. let context = { client: this, event };
  14. this.openFn(context).catch(error => { console.log(error) });
  15. });
  16. this.closeFn = compose(this.closeMiddleware);
  17. this.socket.addEventListener('close', (event) => {
  18. let context = { client: this, event };
  19. this.closeFn(context).then(() => {
  20. }).catch(error => {
  21. console.log(error)
  22. });
  23. });
  24. this.messageFn = compose(this.messageMiddleware);
  25. this.socket.addEventListener('message', (event) => {
  26. let res;
  27. try {
  28. res = JSON.parse(event.data);
  29. } catch (error) {
  30. res = event.data;
  31. }
  32. let context = { client: this, event, res };
  33. this.messageFn(context).then(() => {
  34. }).catch(error => {
  35. console.log(error)
  36. });
  37. });
  38. this.errorFn = compose(this.errorMiddleware);
  39. this.socket.addEventListener('error', (event) => {
  40. let context = { client: this, event };
  41. this.errorFn(context).then(() => {
  42. }).catch(error => {
  43. console.log(error)
  44. });
  45. });
  46. return this;
  47. }

使用koa-compose模块处理中间件。注意context传入了哪些东西,后续定义中间件的时候都已使用。

compose的作用可看这篇文章 傻瓜式解读koa中间件处理模块koa-compose

然后就可以使用了:

  1. new EasySocket()
  2. .openUse((context, next) => {
  3. console.log("open");
  4. next();
  5. })
  6. .closeUse((context, next) => {
  7. console.log("close");
  8. next();
  9. })
  10. .errorUse((context, next) => {
  11. console.log("error", context.event);
  12. next();
  13. })
  14. .messageUse((context, next) => {
  15. //用户登录处理中间件
  16. if (context.res.action === 'userEnter') {
  17. console.log(context.res.user.name+' 进入聊天室');
  18. }
  19. next();
  20. })
  21. .messageUse((context, next) => {
  22. //创建房间处理中间件
  23. if (context.res.action === 'createRoom') {
  24. console.log('创建房间 '+context.res.room.anme);
  25. }
  26. next();
  27. })
  28. .connect('ws://localhost:8080')

可以看到,用户登录与创建房间的逻辑放到两个中间件中分开处理。不足之处就是每个中间件都要判断context.res.action,而这个context.res就是后端返回的数据。怎么消除这个频繁的if判断呢? 我们实现一个简单的消息处理路由。

路由

定义消息路由中间件

messageRouteMiddleware.js

  1. export default (routes) => {
  2. return async (context, next) => {
  3. if (routes[context.req.action]) {
  4. await routes[context.req.action](context,next);
  5. } else {
  6. console.log(context.req)
  7. next();
  8. }
  9. }
  10. }

定义路由

router.js

  1. export default {
  2. userEnter:function(context,next){
  3. console.log(context.res.user.name+' 进入聊天室');
  4. next();
  5. },
  6. createRoom:function(context,next){
  7. console.log('创建房间 '+context.res.room.anme);
  8. next();
  9. }
  10. }

使用:

  1. new EasySocket()
  2. .openUse((context, next) => {
  3. console.log("open");
  4. next();
  5. })
  6. .closeUse((context, next) => {
  7. console.log("close");
  8. next();
  9. })
  10. .errorUse((context, next) => {
  11. console.log("error", context.event);
  12. next();
  13. })
  14. .messageUse(messageRouteMiddleware(router))//使用消息路由中间件,并传入定义好的路由
  15. .connect('ws://localhost:8080')

一切都变得美好了,感觉就像在使用koa。想一个问题,当接收到后端推送的消息时,我们需要做相应的DOM操作。比如路由里面定义的userEnter,我们可能需要在对应的函数里操作用户列表的DOM,追加新用户。这使用原生JS或JQ都是没有问题的,但是如果使用vue,react这些,因为是组件化的,用户列表可能就是一个组件,怎么访问到这个组件实例呢?(当然也可以访问vuex,redux的store,但是并不是所有组件的数据都是用store管理的)。

我们需要一个运行时注册中间件的功能,然后在组件的相应的生命周期钩子里注册中间件并且传入组件实例

运行时注册中间件,修改如下代码:

  1. messageUse(fn, runtime) {
  2. this.messageMiddleware.push(fn);
  3. if (runtime) {
  4. this.messageFn = compose(this.messageMiddleware);
  5. }
  6. return this;
  7. }

修改 messageRouteMiddleware.js

  1. export default (routes,component) => {
  2. return async (context, next) => {
  3. if (routes[context.req.action]) {
  4. context.component=component;//将组件实例挂到context下
  5. await routes[context.req.action](context,next);
  6. } else {
  7. console.log(context.req)
  8. next();
  9. }
  10. }
  11. }

类似vue mounted中使用

  1. mounted(){
  2. let client = this.$wsClients.get("im");//获取指定EasySocket实例
  3. client.messageUse(messageRouteMiddleware(router,this),true)//运行时注册中间件,并传入定义好的路由以及当前组件中的this
  4. }

路由中通过 context.component 即可访问到当前组件。

完美了吗?每次组件mounted 都注册一次中间件,问题很大。所以需要一个判断中间件是否已经注册的功能。也就是一个支持具名注册中间件的功能。这里就暂时不实现了,走另外一条路,也就是之前说到的远程事件的发布与订阅,我们也可以称之为跨进程事件。

跨进程事件

看一段socket.io的代码:

Server (app.js)

  1. var app = require('http').createServer(handler)
  2. var io = require('socket.io')(app);
  3. var fs = require('fs');
  4. app.listen(80);
  5. function handler (req, res) {
  6. fs.readFile(__dirname + '/index.html',
  7. function (err, data) {
  8. if (err) {
  9. res.writeHead(500);
  10. return res.end('Error loading index.html');
  11. }
  12. res.writeHead(200);
  13. res.end(data);
  14. });
  15. }
  16. io.on('connection', function (socket) {
  17. socket.emit('news', { hello: 'world' });
  18. socket.on('my other event', function (data) {
  19. console.log(data);
  20. });
  21. });

Client (index.html)

  1. <script src="/socket.io/socket.io.js"></script>
  2. <script>
  3. var socket = io('http://localhost');
  4. socket.on('news', function (data) {
  5. console.log(data);
  6. socket.emit('my other event', { my: 'data' });
  7. });
  8. </script>

注意力转到这两部分:

服务端

  1. socket.emit('news', { hello: 'world' });
  2. socket.on('my other event', function (data) {
  3. console.log(data);
  4. });

客户端

  1. var socket = io('http://localhost');
  2. socket.on('news', function (data) {
  3. console.log(data);
  4. socket.emit('my other event', { my: 'data' });
  5. });

使用事件,客户端通过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事件名和参数组合规则,以中间件的方式提供处理方法:

  1. export default class EasySocket extends Emitter{//继承Emitter
  2. constructor(config) {
  3. this.url = config.url;
  4. this.openMiddleware = [];
  5. this.closeMiddleware = [];
  6. this.messageMiddleware = [];
  7. this.errorMiddleware = [];
  8. this.remoteEmitMiddleware = [];//新增的部分
  9. this.openFn = Promise.resolve();
  10. this.closeFn = Promise.resolve();
  11. this.messageFn = Promise.resolve();
  12. this.errorFn = Promise.resolve();
  13. this.remoteEmitFn = Promise.resolve();//新增的部分
  14. }
  15. openUse(fn) {
  16. this.openMiddleware.push(fn);
  17. return this;
  18. }
  19. closeUse(fn) {
  20. this.closeMiddleware.push(fn);
  21. return this;
  22. }
  23. messageUse(fn) {
  24. this.messageMiddleware.push(fn);
  25. return this;
  26. }
  27. errorUse(fn) {
  28. this.errorMiddleware.push(fn);
  29. return this;
  30. }
  31. //新增的部分
  32. remoteEmitUse(fn, runtime) {
  33. this.remoteEmitMiddleware.push(fn);
  34. if (runtime) {
  35. this.remoteEmitFn = compose(this.remoteEmitMiddleware);
  36. }
  37. return this;
  38. }
  39. connect(url) {
  40. ...
  41. //新增部分
  42. this.remoteEmitFn = compose(this.remoteEmitMiddleware);
  43. }
  44. //重写emit方法,支持本地调用以远程调用
  45. emit(event, args, isLocal = false) {
  46. let arr = [event, args];
  47. if (isLocal) {
  48. super.emit.apply(this, arr);
  49. return this;
  50. }
  51. let evt = {
  52. event: event,
  53. args: args
  54. }
  55. let remoteEmitContext = { client: this, event: evt };
  56. this.remoteEmitFn(remoteEmitContext).catch(error => { console.log(error) })
  57. return this;
  58. }
  59. }

下面是一个简单的处理中间件:

  1. client.remoteEmitUse((context, next) => {
  2. let client = context.client;
  3. let event = context.event;
  4. if (client.socket.readyState !== 1) {
  5. alert("连接已断开!");
  6. } else {
  7. client.socket.send(JSON.stringify({
  8. type: 'event',
  9. event: event.event,
  10. args: event.args
  11. }));
  12. next();
  13. }
  14. })

意味着调用

  1. client.emit('chatMessage',{
  2. from:'admin',
  3. masg:"Hello WebSocket"
  4. });

就会组合成数据

  1. {
  2. type: 'event',
  3. event: 'chatMessage',
  4. args: {
  5. from:'admin',
  6. masg:"Hello WebSocket"
  7. }
  8. }

发送出去。

服务端接受到这样的数据,可以做相应的数据处理(后面会使用nodejs实现类似的编程模式),也可以直接发送给别的客户端。客户受到类似的数据,可以写专门的中间件进行处理,比如:

  1. client.messageUse((context, next) => {
  2. if (context.res.type === 'event') {
  3. context.client.emit(context.res.event, context.res.args, true);//注意这里的emit是本地emit。
  4. }
  5. next();
  6. })

如果本地订阅的chatMessage事件,回到函数就会被触发。

在vue或react中使用,也会比之前使用路由的方式简单

  1. mounted() {
  2. let client = this.$wsClients.get("im");
  3. client.on("chatMessage", data => {
  4. let isSelf = data.from.id == this.user.id;
  5. let msg = {
  6. name: data.from.name,
  7. msg: data.msg,
  8. createdDate: data.createdDate,
  9. isSelf
  10. };
  11. this.broadcastMessageList.push(msg);
  12. });
  13. }

组件销毁的时候移除相应的事件订阅即可,或者清空所有事件订阅

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

心跳重连

核心代码直接从websocket-heartbeat-js copy过来的(用npm包,还得在它的基础上再包一层),相关文章 初探和实现websocket心跳重连

核心代码:

  1. heartCheck() {
  2. this.heartReset();
  3. this.heartStart();
  4. }
  5. heartStart() {
  6. this.pingTimeoutId = setTimeout(() => {
  7. //这里发送一个心跳,后端收到后,返回一个心跳消息
  8. this.socket.send(this.pingMsg);
  9. //接收到心跳信息说明连接正常,会执行heartCheck(),重置心跳(清除下面定时器)
  10. this.pongTimeoutId = setTimeout(() => {
  11. //此定时器有运行的机会,说明发送ping后,设置的超时时间内未收到返回信息
  12. this.socket.close();//不直接调用reconnect,避免旧WebSocket实例没有真正关闭,导致不可预料的问题
  13. }, this.pongTimeout);
  14. }, this.pingTimeout);
  15. }
  16. heartReset() {
  17. clearTimeout(this.pingTimeoutId);
  18. clearTimeout(this.pongTimeoutId);
  19. }

最后

源码地址:easy-socket-browser

nodejs实现的类似的编程模式(有空再细说):easy-socket-node

实现的聊天室例子:online chat demo

聊天室前端源码:lazy-mock-im

聊天室服务端源码:lazy-mock

以中间件,路由,跨进程事件的姿势使用WebSocket的更多相关文章

  1. 以中间件,路由,跨进程事件的姿势使用WebSocket--Node.js篇

    上一篇文章介绍了在浏览器端以中间件,路由,跨进程事件的姿势使用原生WebSocket.这篇文章将介绍如何使用Node.js以相同的编程模式来实现WebSocket服务端. Node.js中比较流行的两 ...

  2. 自己实现一个Electron跨进程消息组件

    我们知道开发Electron应用,难免要涉及到跨进程通信,以前Electron内置了remote模块,极大的简化了跨进程通信的开发工作,但这也带来了很多问题,具体的细节请参与我之前写的文章: http ...

  3. Android跨进程通信的四种方式

    由于android系统中应用程序之间不能共享内存.因此,在不同应用程序之间交互数据(跨进程通讯)就稍微麻烦一些.在android SDK中提供了4种用于跨进程通讯的方式.这4种方式正好对应于andro ...

  4. 【Chromium中文文档】跨进程通信 (IPC)

    跨进程通信 (IPC) 转载请注明出处:https://ahangchen.gitbooks.io/chromium_doc_zh/content/zh//General_Architecture/I ...

  5. Wayland中的跨进程过程调用浅析

    原文地址:http://blog.csdn.net/jinzhuojun/article/details/40264449 Wayland协议主要提供了Client端应用与Server端Composi ...

  6. 跨进程的mutex

    1.操作系统分为ring0(内核层)和ring3(应用层)两层. ring0层出错会蓝屏,ring3层出错程序就会挂了. event和mutex创建他的指针是应用层,但是它的内部是ring0层,rin ...

  7. [Hook] 跨进程 Binder设计与实现 - 设计篇

    cp from : http://blog.csdn.net/universus/article/details/6211589 关键词 Binder Android IPC Linux 内核 驱动 ...

  8. android 远程Service以及AIDL的跨进程通信

    在Android中,Service是运行在主线程中的,如果在Service中处理一些耗时的操作,就会导致程序出现ANR. 但如果将本地的Service转换成一个远程的Service,就不会出现这样的问 ...

  9. android 跨进程通信

    转自:http://www.androidsdn.com/article/show/137 由于android系统中应用程序之间不能共享内存.因此,在不同应用程序之间交互数据(跨进程通讯)就稍微麻烦一 ...

随机推荐

  1. Unity Shader序列帧动画学习笔记

    Unity Shader序列帧动画学习笔记 关于无限播放序列帧动画的一点问题 在学shader的序列帧动画时,书上写了这样一段代码: fixed4 frag(v2f i){ // 获得整数时间 flo ...

  2. day 82 URL分发

    一 .admin 流程 (1) 启动 autodiscover_modules('admin', register_to=site) (2) 注册 单例模式 admin.site=AdminSite( ...

  3. STL在数组算法的使用

    find(a:起始位置 , b: 终止位置 , c: 要查找的内容)      ------>查找寻找内容的位置 count(a:起始位置 , b: 终止位置 , c: 要查找的内容)   -- ...

  4. Lunix git stash clear 或者 git stash drop后恢复的方法

    首先输入 git fsck --lost-found 会看到 一条一条的记录 这里的"dangling commit ..."你可以理解为记录的是你stash的id(经测试,该id ...

  5. 关于使用Iscroll.js异步加载数据后不能滑动到最底端的问题解决方案

    关于使用Iscroll.js异步加载数据后不能滑动到最底端,拉到最下边又弹回去的问题困扰了我老半天,相信很多朋友都遇到了.我刚好不小心解决了,和大家分享一下.由于各种忙,下边就直接上代码吧. (前提是 ...

  6. protobuffer php使用

    protobuffer是google推出的一种数据传的方式,具体压缩,体积小的特点 protobuffer本身不支持php,若要把.proto文件转化为php支持的文件,需要使用第三方的程序 alle ...

  7. 微服务是"银弹"吗?

    前言:所谓"银弹",本意是用金属银做成的子弹:在古老的传说里它是杀死狼人的有效武器.在著作<人月神话>也有描述.微服务是当前软件界流行的名词,那么它能成为像银弹一样厉害 ...

  8. 直接插入排序实现(Java)

    直接插入排序介绍 直接插入排序的基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的.记录数增1的有序表.     怎么理解呢?就是将n个待排序的元素看成一个有序表和一个无序表,开始时有序 ...

  9. POJ 1062

    #include<iostream> #include<stdio.h> #define MAXN 105 #define inf 10000000 using namespa ...

  10. LODOP内嵌挡住浏览器的div弹出层

    首先,做一个简单的div弹出层用于测试,该弹出层的介绍可查看本博客另一篇博文:[JS新手教程]浏览器弹出div层 然后加入LODOP内嵌,LODOP可以内嵌,C-LODOP不能内嵌,可以在IE等浏览器 ...