title: Socket.IO --- **Socket.IO** 是一个基于 Node.js 的实时应用程序框架,在即时通讯、通知与消息推送,实时分析等场景中有较为广泛的应用。 WebSocket 的产生源于 Web 开发中日益增长的实时通信需求,对比基于 http 的轮询方式,它大大节省了网络带宽,同时也降低了服务器的性能消耗; [socket.io] 支持 websocket、polling 两种数据传输方式以兼容浏览器不支持 WebSocket 场景下的通信需求。 框架提供了 [egg-socket.io] 插件,增加了以下开发规约: - namespace: 通过配置的方式定义 namespace(命名空间) - middleware: 对每一次 socket 连接的建立/断开、每一次消息/数据传递进行预处理 - controller: 响应 socket.io 的 event 事件 - router: 统一了 socket.io 的 event 与 框架路由的处理配置方式 ## 安装 egg-socket.io ### 安装 ```bash $ npm i egg-socket.io --save ``` **开启插件:** ```js // {app_root}/config/plugin.js exports.io = { enable: true, package: 'egg-socket.io', }; ``` ### 配置 ```js // {app_root}/config/config.${env}.js exports.io = { init: { }, // passed to engine.io namespace: { '/': { connectionMiddleware: [], packetMiddleware: [], }, '/example': { connectionMiddleware: [], packetMiddleware: [], }, }, }; ``` > 命名空间为 `/` 与 `/example`, 不是 `example` **uws:** 如果想要使用 [uws] 替代默认的 `us` 可以做如下配置 ```js // {app_root}/config/config.${env}.js exports.io = { init: { wsEngine: 'uws' }, // default: us }; ``` **redis:** [egg-socket.io] 内置了 `socket.io-redis`,在 cluster 模式下,使用 redis 可以较为简单的实现 clients/rooms 等信息共享 ```js // {app_root}/config/config.${env}.js exports.io = { redis: { host: { redis server host }, port: { redis server prot }, auth_pass: { redis server password }, db: 0, }, }; ``` > 开启 `redis` 后,程序在启动时会尝试连接到 redis 服务器 > 此处 `redis` 仅用于存储连接实例信息,参见 [#server.adapter](https://socket.io/docs/server-api/#server-adapter-value) **注意:** 如果项目中同时使用了 `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; } ``` ## 使用 egg-socket.io 开启 [egg-socket.io] 的项目目录结构如下: ``` chat ├── 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 请求。 虽然框架通过插件尽量统一了它们的风格,但务必注意,它们的使用场景是不一样的。详情参见 issue:[#1416](https://github.com/eggjs/egg/issues/1416) #### Connection 在每一个客户端连接或者退出时发生作用,故而我们通常在这一步进行授权认证,对认证失败的客户端做出相应的处理 ```js // {app_root}/app/io/middleware/connection.js module.exports = app => { return async (ctx, next) => { ctx.socket.emit('res', 'connected!'); await next(); // execute when disconnect. console.log('disconnection!'); }; }; ``` 踢出用户示例: ```js const tick = (id, msg) => { logger.debug('#tick', id, msg); socket.emit(id, msg); app.io.of('/').adapter.remoteDisconnect(id, true, err => { logger.error(err); }); }; ``` 同时,针对当前的连接也可以简单处理: ```js // {app_root}/app/io/middleware/connection.js module.exports = app => { return async (ctx, next) => { if (true) { ctx.socket.disconnet(); return; } await next(); console.log('disconnection!'); }; }; ``` #### Packet 作用于每一个数据包(每一条消息);在生产环境中,通常用于对消息做预处理,又或者是对加密消息的解密等操作 ```js // {app_root}/app/io/middleware/packet.js module.exports = app => { return async (ctx, next) => { ctx.socket.emit('res', 'packet received!'); console.log('packet:', this.packet); await next(); }; }; ``` ### Controller Controller 对客户端发送的 event 进行处理;由于其继承于 `egg.Contoller`, 拥有如下成员对象: - ctx - app - service - config - logger > 详情参考 [Controller](../basics/controller.md) 文档 ```js // {app_root}/app/io/controller/default.js 'use strict'; const Controller = require('egg').Controller; class DefaultController 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}`); } } module.exports = DefaultController; // or async functions exports.ping = async function() { const message = this.args[0]; await this.socket.emit('res', `Hi! I've got your message: ${message}`); }; ``` ### Router 路由负责将 socket 连接的不同 events 分发到对应的 controller,框架统一了其使用方式 ```js // {app_root}/app/router.js module.exports = app => { const { router, controller, io } = app; // default router.get('/', controller.home.index); // socket.io io.of('/').route('server', io.controller.home.server); }; ``` **注意:** nsp 有如下的系统事件: - `disconnecting` doing the disconnect - `disconnect` connection has disconnected. - `error` Error occurred ### Namespace/Room #### Namespace (nsp) namespace 通常意味分配到不同的接入点或者路径,如果客户端没有指定 nsp,则默认分配到 "/" 这个默认的命名空间。 在 socket.io 中我们通过 `of` 来划分命名空间;鉴于 nsp 通常是预定义且相对固定的存在,框架将其进行了封装,采用配置的方式来划分不同的命名空间。 ```js // socket.io var nsp = io.of('/my-namespace'); nsp.on('connection', function(socket){ console.log('someone connected'); }); nsp.emit('hi', 'everyone!'); // egg exports.io = { namespace: { '/': { connectionMiddleware: [], packetMiddleware: [], }, }, }; ``` #### Room room 存在于 nsp 中,通过 join/leave 方法来加入或者离开; 框架中使用方法相同; ```js const room = 'default_room'; module.exports = app => { return async (ctx, next) => { ctx.socket.join(room); ctx.app.io.of('/').to(room).emit('online', { msg: 'welcome', id: ctx.socket.id }); await next(); console.log('disconnection!'); }; }; ``` **注意:** 每一个 socket 连接都会拥有一个随机且不可预测的唯一 id `Socket#id`,并且会自动加入到以这个 `id` 命名的 room 中 ## 实例 这里我们使用 [egg-socket.io] 来做一个支持 p2p 聊天的小例子 ### client UI 相关的内容不重复写了,通过 window.socket 调用即可 ```js // browser const log = console.log; window.onload = function () { // init const socket = io('/', { // 实际使用中可以在这里传递参数 query: { room: 'demo', userId: `client_${Math.random()}`, }, transports: ['websocket'] }); socket.on('connect', () => { const id = socket.id; log('#connect,', id, socket); // 接收在线用户信息 socket.on('online', msg => { log('#online,', msg); }); // 监听自身 id 以实现 p2p 通讯 socket.on(id, msg => { log('#receive,', msg); }); // 系统事件 socket.on('disconnect', msg => { log('#disconnect', msg); }); socket.on('disconnecting', () => { log('#disconnecting'); }); socket.on('error', () => { log('#error'); }); }); window.socket = socket; }; ``` #### 微信小程序 微信小程序提供的 API 为 WebSocket ,而 socket.io 是 Websocket 的上层封装,故我们无法直接用小程序的 API 连接,可以使用类似 [wxapp-socket-io](https://github.com/wxsocketio/wxapp-socket-io) 的库来适配。 示例代码如下: ```js // 小程序端示例代码 import io from 'vendor/wxapp-socket-io.js'; const socket = io('ws://127.0.0.1:7001'); socket.on('connect', function () { socket.emit('chat', 'hello world!'); }); socket.on('res', msg => { console.log('res from server: %s!', msg); }); ``` ### server 以下是 demo 的部分代码并解释了各个方法的作用 #### config ```js // {app_root}/config/config.${env}.js exports.io = { namespace: { '/': { connectionMiddleware: [ 'auth' ], packetMiddleware: [ ], // 针对消息的处理暂时不实现 }, }, // cluster 模式下,通过 redis 实现数据共享 redis: { host: '127.0.0.1', port: 6379, }, }; ``` #### helper 框架扩展用于封装数据格式 ```js // {app_root}/app/extend/helper.js module.exports = { parseMsg(action, payload = {}, metadata = {}) { const meta = Object.assign({}, { timestamp: Date.now(), }, metadata); return { data: { action, payload, }, meta, }; }, }; ``` Format: ```js { data: { action: 'exchange', // 'deny' || 'exchange' || 'broadcast' payload: {}, }, meta:{ timestamp: 1512116201597, client: '/webrtc#nNx88r1c5WuHf9XuAAAB', target: '/webrtc#nNx88r1c5WuHf9XuAAAB' }, } ``` #### middleware [egg-socket.io] 中间件负责 socket 连接的处理 ```js // {app_root}/app/io/middleware/auth.js const PREFIX = 'room'; module.exports = () => { return async (ctx, next) => { const { app, socket, logger, helper } = ctx; const id = socket.id; const nsp = app.io.of('/'); const query = socket.handshake.query; // 用户信息 const { room, userId } = query; const rooms = [room]; logger.debug('#user_info', id, room, userId); const tick = (id, msg) => { logger.debug('#tick', id, msg); // 踢出用户前发送消息 socket.emit(id, helper.parseMsg('deny', msg)); // 调用 adapter 方法踢出用户,客户端触发 disconnect 事件 nsp.adapter.remoteDisconnect(id, true, err => { logger.error(err); }); }; // 检查房间是否存在,不存在则踢出用户 // 备注:此处 app.redis 与插件无关,可用其他存储代替 const hasRoom = await app.redis.get(`${PREFIX}:${room}`); logger.debug('#has_exist', hasRoom); if (!hasRoom) { tick(id, { type: 'deleted', message: 'deleted, room has been deleted.', }); return; } // 当用户加入时 nsp.adapter.clients(rooms, (err, clients) => { // 追加当前 socket 信息到clients clients[id] = query; // 加入房间 socket.join(room); logger.debug('#online_join', _clients); // 更新在线用户列表 nsp.to(room).emit('online', { clients, action: 'join', target: 'participator', message: `User(${id}) joined.`, }); }); await next(); // 当用户离开时 nsp.adapter.clients(rooms, (err, clients) => { logger.debug('#leave', room); const _clients = {}; clients.forEach(client => { const _id = client.split('#')[1]; const _client = app.io.sockets.sockets[_id]; const _query = _client.handshake.query; _clients[client] = _query; }); logger.debug('#online_leave', _clients); // 更新在线用户列表 nsp.to(room).emit('online', { clients: _clients, action: 'leave', target: 'participator', message: `User(${id}) leaved.`, }); }); }; }; ``` #### controller P2P 通信,通过 exchange 进行数据交换 ```js // {app_root}/app/io/controller/nsp.js const Controller = require('egg').Controller; 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 }); nsp.emit(target, msg); } catch (error) { app.logger.error(error); } } } module.exports = NspController; ``` #### router ```js // {app_root}/app/router.js module.exports = app => { const { router, controller, io } = app; router.get('/', controller.home.index); // socket.io io.of('/').route('exchange', io.controller.nsp.exchange); }; ``` 开两个 tab 页面,并调出控制台: ```js socket.emit('exchange', { target: '/webrtc#Dkn3UXSu8_jHvKBmAAHW', payload: { msg : 'test', }, }); ``` ![](https://raw.githubusercontent.com/eggjs/egg/master/docs/assets/socketio-console.png) ## 参考链接 - [socket.io] - [egg-socket.io] - [egg-socket.io example](https://github.com/eggjs/egg-socket.io/tree/master/example) [socket.io]: https://socket.io [egg-socket.io]: https://github.com/eggjs/egg-socket.io [uws]: https://github.com/uWebSockets/uWebSockets