如今几乎所有的软件产品中都加入了社交的功能。即时通信是非常重要而又常用的一个技术点。H5之前大家一般用Flash之类的技术,完成通信,至今很多网站的客服系统还是用这个做的。 但H5出来之后你懂的,让通信一下子变得很简单。

WebSocket是HTML5最新提出的规范,虽然主流浏览器都已经支持,但仍然可能有不兼容的情况,为了兼容所有浏览器,给程序员提供一致的编程体验,SocketIO将 WebSocket、AJAX和其它的通信方式全部封装成了统一的通信接口,也就是说,我们在使用SocketIO时,不用担心兼容问题,底层会自动选用最佳的通信方式。因此说,WebSocket是SocketIO的一个子集。今天我们就用 socket.io来实现一下简单的聊天应用。

# Socket.IO

Socket.IO 是一个基于 Node.js 的实时应用程序框架,在即时通讯、通知与消息推送,实时分析等场景中有较为广泛的应用。

WebSocket 的产生源于 Web 开发中日益增长的实时通信需求,对比基于 http 的轮询方式,它大大节省了网络带宽,同时也降低了服务器的性能消耗; socket.io (opens new window) 支持 websocket、polling 两种数据传输方式以兼容浏览器不支持 WebSocket 场景下的通信需求。

框架提供了 egg-socket.io (opens new window) 插件,增加了以下开发规约:

  • namespace: 通过配置的方式定义 namespace(命名空间)
  • middleware: 对每一次 socket 连接的建立/断开、每一次消息/数据传递进行预处理
  • controller: 响应 socket.io 的 event 事件
  • router: 统一了 socket.io 的 event 与 框架路由的处理配置方式

# 安装 egg-socket.io

安装

$ npm i egg-socket.io --save

开启插件:

config/plugin.js:
io: {
   enable: true,
   package: 'egg-socket.io',
}

配置

config/config.default.ts:
config.io = {
    init: { }, // 传递给engine.io
    namespace: {
      '/': {
        connectionMiddleware: [],
        packetMiddleware: [],
      },
      '/example': {
        connectionMiddleware: [],
        packetMiddleware: [],
      },
    },
  };

命名空间为 / 与 /example, 不是 example

egg-socket.io (opens new window) 内置了 socket.io-redis,在 cluster 模式下,使用 redis 可以较为简单的实现 clients/rooms 等信息共享:

 config.io = {
   ...
    redis: {
      host: '127.0.0.1',
      port: 6379,
      auth_pass: '',
      db: 0,
    },
  };

开启 redis 后,程序在启动时会尝试连接到 redis 服务器 此处 redis 仅用于存储连接实例信息,参见 #server.adapter (opens new window)

注意: 如果项目中同时使用了 egg-redis, 请单独配置,不可共用。

# 部署

框架是以 Cluster 方式启动的,而 socket.io 协议实现需要 sticky 特性支持,否则在多进程模式下无法正常工作。

由于 socket.io 的设计,在多进程中服务器必须在 sticky 模式下工作,故需要给 startCluster 传递 sticky 参数。

修改 package.json 中 npm scripts 脚本:

{
  "scripts": {
    "dev": "egg-bin dev --sticky",
    "start": "egg-scripts start --sticky"
  }
}

# Nginx 配置

location / {
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header Host $host;
  proxy_pass   http://127.0.0.1:7001;

  # http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_bind
  # proxy_bind       $remote_addr transparent;
}

# 使用 egg-socket.io

开启 egg-socket.io 的项目目录结构如下:

xxxx
├── app
│   ├── extend
│   │   └── helper.js
│   ├── io
│   │   ├── controller
│   │   │   └── default.js
│   │   └── middleware
│   │       ├── connection.js
│   │       └── packet.js
│   └── router.js
├── config
└── package.json

注意:对应的文件都在 app/io 目录下

# Middleware

  • 中间件有如下两种场景:
    • Connection
    • Packet

其配置于各个命名空间下,根据上述两种场景分别发生作用。

注意:如果我们启用了框架中间件,则会发现项目中有以下目录:

  • app/middleware:框架中间件
  • app/io/middleware:插件中间件

区别:

  • 框架中间件基于 http 模型设计,处理 http 请求。
  • 插件中间件基于 socket 模型设计,处理 socket.io 请求。
  • 虽然框架通过插件尽量统一了它们的风格,但务必注意,它们的使用场景是不一样的。

Connection

在每一个客户端连接或者退出时发生作用,故而我们通常在这一步进行授权认证,对认证失败的客户端做出相应的处理:

// app/io/middleware/connection.ts
import { Context, Application } from 'egg';

export default function ConnectionMiddleware(_options: any, _app: Application) {
  return async (ctx: Context, next: any) => {
   ctx.socket.emit('res', 'connected!');
    await next();
    // 断开连接时执行。
    console.log('disconnection!');
  };
}

踢出用户示例:

const tick = (id, msg) => {
  logger.debug('#tick', id, msg);
  socket.emit(id, msg);
  app.io.of('/').adapter.remoteDisconnect(id, true, err => {
    logger.error(err);
  });
};

同时,针对当前的连接也可以简单处理:

// app/io/middleware/connection.ts
import { Context, Application } from 'egg';

export default function ConnectionMiddleware(_options: any, _app: Application) {
  return async (ctx: Context, next: any) => {
    if (true) {
      ctx.socket.disconnect();
      return;
    }
    await next();
    console.log('断开!');
  };
}

Packet

作用于每一个数据包(每一条消息);在生产环境中,通常用于对消息做预处理,又或者是对加密消息的解密等操作:

// app/io/middleware/packet.ts
import { Context, Application } from 'egg';

export default function ConnectionMiddleware(_options: any, _app: Application) {
  return async (ctx: Context, next: any) => {
    ctx.socket.emit('res', '收到数据包!');
    console.log('packet:', this.packet);
    await next();
  };
}

Controller

Controller 对客户端发送的 event 进行处理;由于其继承于 egg.Contoller, 拥有如下成员对象:

  • ctx
  • app
  • service
  • config
  • logger

详情参考 Controller 文档 https://eggjs.org/zh-cn/basics/controller.html

// app/io/controller/default.ts

import { Controller } from 'egg';

export default class NspController extends Controller {
  async ping() {
    const { ctx, app } = this;
    const message = ctx.args[0];
    await ctx.socket.emit('res', `Hi! I've got your message: ${message}`);
  }
}

# Router

https://github.com/push-over/egg-example/blob/master/app/router.ts

路由负责将 socket 连接的不同 events 分发到对应的 controller,框架统一了其使用方式

import { Application } from 'egg';

export default (app: Application) => {
  const { router, controller, io } = app;

  router.get('/', controller.home.index);

  // tslint:disable-next-line: no-string-literal
  io.of('/').route('server', io.controller.['home'].server);
};

注意:

nsp 有如下的系统事件:

  • disconnecting 断开连接。
  • disconnect 连接已断开。
  • error 发生了错误。

Namespace/Room

Namespace (nsp)

namespace 通常意味分配到不同的接入点或者路径,如果客户端没有指定 nsp,则默认分配到 "/" 这个默认的命名空间。

在 socket.io 中我们通过 of 来划分命名空间;鉴于 nsp 通常是预定义且相对固定的存在,框架将其进行了封装,采用配置的方式来划分不同的命名空间。

const nsp = io.of('/my-namespace');
nsp.on('connection', function(socket){
  console.log('someone connected');
});
nsp.emit('hi', 'everyone!');

// egg
config.io = {
    init: { },
    namespace: {
      '/': {
        connectionMiddleware: [ 'auth' ],
        packetMiddleware: [],
      },
    },
};

Room

room 存在于 nsp 中,通过 join/leave 方法来加入或者离开; 框架中使用方法相同;

const room = 'default_room';

import { Context, Application } from 'egg';

export default function AuthMiddleware(_options: any, _app: Application) {
  return async (ctx: Context, next: any) => {
    ctx.socket.join(room);
    ctx.app.io.of('/').to(room).emit('online', { msg: 'welcome', id: ctx.socket.id });
    await next();
    console.log('断开');
  };
}

注意: 每一个 socket 连接都会拥有一个随机且不可预测的唯一 id Socket#id,并且会自动加入到以这个 id 命名的 room 中。

# 实例

这里我们使用 egg-socket.io 来做一个支持聊天的小例子。

# 客户端 client

UI 相关的内容我就不带大家一步步写了,大家可以拉取源码进行实践,在这我们通过 window.socket调用即可。

具体的样子如下图:

既然我们基于 socket.io 那我们的客户端也需要使用scoket.io 才能完成和服务器端的通信。首先我们引入 socket.io 和 vue 的 CDN。

<!DOCTYPE html>
<html lang="en">
...

<body>
  <div id="app">
 	...
  </div>
  <script src="https://cdn.bootcss.com/vue/2.6.10/vue.js"></script>
  <script src="https://cdn.bootcss.com/socket.io/2.3.0/socket.io.js"></script>
</body>
</html>

我们先来了解一个知识点:

  • socket.emit 表示发送了一个 action 命令
  • socket.on 表示接收一个 action 命令

接下来我们来完成我们客户端的代码:

<script>
  const app = new Vue({
    el: '#app',
    data: {
      user: null,	// 用户
      userList: [],	// 用户列表
      messageList: [], // 消息列表
      search: '',	   // search user
      target: '群聊',	  // 目标
      text: '',		// 文本消息
      tipsList: [],	// 消息提示
    },
     mounted() {
      const _this = this
      // 请注意我们在调用 io() 时没有指定任何 URL,因为它默认将尝试连接到提供当前页面的主机。
      const socket = io('/', {
        // 实际使用中可以在这里传递参数
        query: {
          room: 'demo'
        },
        transports: ['websocket']
      });
      socket.on('connect', () => {
        const id = socket.id;
        log('#connect,', id, socket);
        // 监听自身 id 以实现通讯
        socket.on(id, msg => {
          log('#receive,', msg);
        });
      });
      // 接收在线用户信息
      socket.on('online', msg => {
        log('#online,', msg);
      });
      // 系统事件
      socket.on('disconnect', msg => {
        console.log('服务器异常,已与服务器失去联系!', msg)
      });
      socket.on('disconnecting', () => {
        console.log('断开连接!', msg)
      });
      socket.on('error', () => {
        console.log('未知错误!')
      });
      window.socket = socket;
    }
  })
</script>

接下来我们来完善发送消息:

<script>
    ...
    inputing(e) {
        const _this = this
        // 在这里我们是使用 Enter 发送的
        if (e.keyCode === 13 && _this.text.trim().length) {
          // exchange  就是socket.io 的路由
          // 传给服务器端一个要通讯的目标 (target)
          window.socket.emit('exchange', {
            target: _this.target,
            payload: {
              msg: _this.text,
            },
          });
          // 添加到消息列表
          _this.messages(_this.target, true, _this.text)
          _this.text = ''
        }
      }
    ...
</script>

发送消息我们完成了,那接收消息呢? 接收消息也很简单,我们来看代码:

<script>
    ...
     socket.on('connect', () => {
        const id = socket.id;
        _this.user = id
        // 接收通讯目标的消息
        socket.on(id, msg => {
          const { data, meta } = msg
          const date = new Date(meta.timestamp)
          // 添加到消息列表
          _this.messages(meta.client, false, data.payload.msg, date)
          if (!_this.tipsList.includes(meta.client)) _this.tipsList.push(meta.client)
       });
    ...
</script>

是不是发现 socket.io 仅用简短的代码就完成了一个聊天案例啊

# 服务端 server

扩展

框架扩展用于封装数据格式

// {app_root}/app/extend/helper.ts

export default {
  parseMsg(action: string, payload = {}, metadata = {}) {
    const meta = Object.assign({}, {
      timestamp: Date.now(),
    }, metadata);

    return {
      meta,
      data: {
        action,
        payload,
      },
    };
  },
};

中间件

gg-socket.io 中间件负责 socket 连接的处理,我们建立一个 auth 中间件,来做一些用户加入和离开的操作:

const PREFIX = 'room';

import { Context, Application } from 'egg';

export default function AuthMiddleware(_options: any, _app: Application) {
  return async (ctx: Context, next: any) => {
    const { app, socket, logger, helper } = ctx;
    const id = socket.id;
    const nsp = app.io.of('/');
    const query = socket.handshake.query;

    const { room } = query;
    const rooms = [ room ];

    const tick = (id: any, msg: {} | undefined) => {
      // 踢出用户前发送消息
      socket.emit(id, helper.parseMsg('deny', msg));
      // 调用 adapter 方法踢出用户,客户端触发 disconnect 事件
      // tslint:disable-next-line: no-string-literal
      nsp['adapter'].remoteDisconnect(id, true, (err: any) => {
        logger.error(err);
      });
    };

    // 检查房间是否存在,不存在则踢出用户
    const hasRoom = await app.redis.get(`${PREFIX}:${room}`);

    if (!hasRoom) {
      tick(id, {
        type: '已删除',
        message: '删除,房间已删除.',
      });
      return;
    }

    // 用户加入
    socket.join(room);

    // 在线列表
    // tslint:disable-next-line: no-string-literal
    nsp['adapter'].clients(rooms, (_err: any, clients: any) => {
      // 更新在线用户列表
      // tslint:disable-next-line: no-string-literal
      nsp['to'](room).emit('online', {
        clients,
        action: '加入',
        target: '参加者',
        message: `用户(${id})已加入.`,
      });
    });

    await next();

    // tslint:disable-next-line: no-string-literal
    nsp['adapter'].clients(rooms, (_err: any, clients: any) => {
      // 更新在线用户列表
      // tslint:disable-next-line: no-string-literal
      nsp['to'](room).emit('online', {
        clients,
        action: '离开',
        target: '参加者',
        message: `用户(${id})已离开.`,
      });
    });
  };
}

控制器

通信,通过 exchange 进行数据交换

import { Controller } from 'egg';

export default class NspController extends Controller {
  async exchange() {
    const { ctx, app } = this;
    const nsp = app.io.of('/');
    // 客户端传递的消息
    const message = ctx.args[0] || {};
    const socket = ctx.socket;
    const client = socket.id;

    try {
      const { target, payload } = message;
      if (!target) return;
      const msg = ctx.helper.parseMsg('exchange', payload, { client, target });
      // 判断是群聊还是私聊
      if (target === '群聊') {
        // 广播:发送给不包括自己的所有人
        socket.broadcast.emit(target, msg);
      } else {
        // tslint:disable-next-line: no-string-literal
        nsp['emit'](target, msg);
        // socket.emit(target, msg);
      }
    } catch (error) {
      app.logger.error(error);
    }
  }
}

路由

建立 socket.io 路由进行测试:

import { Application } from 'egg';

export default (app: Application) => {
  const { router, controller, io } = app;

  // tslint:disable-next-line: no-string-literal
  io.of('/').route('exchange', io.controller[ 'nsp' ].exchange);
};

多开几个 tab 页面,并测试发送消息:

大家有没有感觉使用 socket.io 只用简短的代码就实现了一个简易的聊天应用呢,有没有感觉很方便。当然 socket.io 还有很多 api,大家有兴趣可以去研究研究该怎么样优化我们这个案例。

# 课外练习

下面是一些可以做的优化:

  • 当用户连接和断开连接时广播消息
  • 添加昵称
  • 添加 “{用户} 正在输入” 功能
  • 显示在线用户
  • 添加发送表情包
  • 添加发送语音
阅读全文