egg/docs/source/zh-cn/tutorials/socketio.md

633 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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