Eggjs学习系列(六) Socket.IO实践
Socket.IO 是一个基于 Node.js 的实时应用程序框架,在即时通讯、通知与消息推送,实时分析等场景中有较为广泛的应用。
Eggjs提供了 egg-socket.io插件来实现 websocket 通信,并增加了下列开发规约:
namespace: 通过配置的方式定义 namespace(命名空间)middleware: 对每一次 socket 连接的建立/断开、每一次消息/数据传递进行预处理controller: 响应 socket.io 的 event 事件router: 统一了 socket.io 的 event 与 框架路由的处理配置方式
安装和配置
npm i egg-socket.io --save
开启插件
// app/config/plugin.ts
import { EggPlugin } from 'egg';
const plugin: EggPlugin = {
// static: true,
io: {
enable: true,
package: 'egg-socket.io',
},
};
export default plugin;
添加配置
// app/config/config.default.ts
import { EggAppConfig, EggAppInfo, PowerPartial } from 'egg';
export default (appInfo: EggAppInfo) => {
const config = {} as PowerPartial<EggAppConfig>;
// add your special config in here
const bizConfig = {
io: {
init: {}, // 设置引擎, 默认 ws 引擎
namespace: {
// namespace(nsp) 通常意味分配到不同的接入点或者路径。
// 如果客户端没有指定 nsp,则默认分配到 `/` 这个默认的命名空间。
'/io': {
connectionMiddleware: [],
packetMiddleware: [],
},
},
},
};
// the return config will combines to EggAppConfig
return {
...config,
...bizConfig
};
};
使用 redis
egg-socket.io内置了socket.io-redis,可在 cluster 模式下,使用 redis 实现信息共享
// app/config/config.default.ts
const bizConfig = {
io: {
init: {}, // 设置引擎, 默认 ws 引擎
namespace: {
// namespace(nsp) 通常意味分配到不同的接入点或者路径。
// 如果客户端没有指定 nsp,则默认分配到 `/` 这个默认的命名空间。
'/io': {
connectionMiddleware: [],
packetMiddleware: [],
},
},
// 配置 redis
redis: {
host: '127.0.0.1',
port: 6379,
},
},
};
注意:
框架是以 Cluster 方式启动的,而 socket.io 协议实现需要 sticky 特性支持,否则在多进程模式下无法正常工作。 需要在启动命令添加 sticky 参数:
{
"scripts": {
"dev": "egg-bin dev --sticky",
"start": "egg-scripts start --sticky"
}
}
基本使用
在 app 目录下创建 io 用于存放 socket 相关代码
为了使用 Typescript 中的智能提示功能,需要在 index.d.ts 中引入 egg-socket.io
// typings/index.d.ts
import 'egg-socket.io'
引入之后,会发现使用 app.io 等的时候会有代码提示。为了能够使用 socket.io 的智能提示,还需要再安装
yarn add @types/socket.io
首先在 helper 中添加函数,用于封装 socket 交互的基本数据格式
// app/extend/helper.ts
// 添加扩展
export default {
/**
* 封装 socket 请求数据格式
* @param action 事件
* @param payload 数据
* @param metadata 元信息
*/
parseMsg(action, payload = {}, metadata = {}) {
// 封装 meta 数据,添加当前时间轴
const meta = Object.assign({}, {
timestamp: Date.now(),
}, metadata);
// 格式化返回数据
return {
meta,
data: {
action,
payload,
},
};
},
};
Middleware
接下来在 middleware 中间件中处理 socket 连接。
注意: socket 中间件位于 app/io/middleware 目录下,用于处理 socket.io 的请求。
// app/io/middleware/auth.ts
import { Context } from 'egg';
const PREFIX = 'room';
export default function AuthMiddleware() {
return async (ctx: Context, next: () => Promise<any>) => {
const { app, socket, logger, helper } = ctx;
const id = socket.id;
const nsp = app.io.of('/io');
const query = socket.handshake.query;
// 用户信息
const { room, userId } = query;
const rooms = [ room ];
logger.debug('#user_info', id, room, userId);
// 踢出连接者的函数
const tick = (id: string, 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 与egg-socket.io插件无关,用的是 egg-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;
}
// 用户加入
logger.debug('#join', room);
// room 存在于 nsp 中,通过 `join/leave` 方法来加入或者离开
socket.join(room);
// 在线列表
nsp.adapter.clients(rooms, (_, clients) => {
logger.debug('#online_join', clients);
// 更新在线用户列表
// 使用 to 指定房间, emit 往房间发送消息
nsp.to(room).emit('online', {
clients,
action: 'join',
target: 'participator',
message: `User(${id}) joined.`,
});
});
await next();
// 用户离开
logger.debug('#leave', room);
// 在线列表
nsp.adapter.clients(rooms, (_, clients) => {
logger.debug('#online_leave', clients);
// 更新在线用户列表
// 使用 to 指定房间, emit 往房间发送消息
nsp.to(room).emit('online', {
clients,
action: 'leave',
target: 'participator',
message: `User(${id}) leaved.`,
});
});
};
}
在config 中加入 auth 中间件
const bizConfig = {
redis: {
client: {
port: 6379,
host: '127.0.0.1',
password: '',
db: 0,
},
},
io: {
init: {}, // 设置引擎, 默认 ws 引擎
namespace: {
'/io': {
connectionMiddleware: [
'auth',
],
packetMiddleware: [],
},
},
// 配置 redis
redis: {
host: '127.0.0.1',
port: 6379,
},
},
};
controller
Controller 对客户端发送的 event 进行处理;由于其继承于 egg.Contoller。接下来实现 P2P 通信,通过 exchange 进行数据交换。
// app/io/controller/nsp.ts
import { Controller } from 'egg';
export default class NspController extends Controller {
public 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);
}
}
}
router
路由负责将 socket 连接的不同 events 分发到对应的 controller
// app/router.ts
import { Application } from 'egg';
export default (app: Application) => {
const { controller, router, io } = app;
io.of('/io').route('exchange', io.controller.nsp.exchange);
};
注意:
nsp 有如下的系统事件:
disconnectingdoing the disconnectdisconnectconnection has disconnected.errorError occurred
Namespace (nsp)
通过 config 配置 namespace 来配置不同命名空间。
io: {
namespace: {
'/': {
connectionMiddleware: [],
packetMiddleware: [],
},
},
}
最后,在项目目录创建 app.ts 用于开始配置 房间
// app.ts
// app.ts
import { Application, IBoot } from 'egg';
// 声明周期
export default class AppBoot implements IBoot {
private readonly app: Application;
constructor(app: Application) {
this.app = app;
}
configWillLoad() {
// Ready to call configDidLoad,
// Config, plugin files are referred,
// this is the last chance to modify the config.
}
configDidLoad() {
// Config, plugin files have loaded.
}
async didLoad() {
// All files have loaded, start plugin here.
}
async willReady() {
// All plugins have started, can do some thing before app ready.
// 设置 room
const room = await this.app.redis.get('room:demo');
if (!room) {
await this.app.redis.set('room:demo', 'demo');
}
}
async didReady() {
// Worker is ready, can do some things
// don't need to block the app boot.
}
async serverDidReady() {
// Server is listening.
}
async beforeClose() {
// Do some thing before app close.
}
}
这样,服务端就开发完成了,接下来编写前端页面用于与后端创建 socket 连接
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Demo</title>
<style>
body {
overflow-x: hidden;
}
.console-wrapper {
margin: auto;
padding: 12px;
width: 80%;
background: #eee;
}
</style>
</head>
<body>
<div class="console-wrapper">
<pre id="console"></pre>
</div>
<script src="https://cdn.bootcss.com/socket.io/2.1.0/socket.io.js"></script>
<script src="https://cdn.bootcss.com/lodash.js/4.17.10/lodash.min.js"></script>
<script>
// 不重要的代码,仅展示使用 - start
const con = document.querySelector('#console');
const doc = document.documentElement;
const wh = document.documentElement.clientHeight;
const _scrollToBottom = (function() {
return _.throttle(function() {
doc.scrollTop = doc.scrollHeight;
}, 100);
})();
const scrollToBottom = function() {
if (doc.scrollHeight > wh) {
_scrollToBottom();
}
};
const log = function() {
let msgList = [].slice.apply(arguments);
msgList = msgList.map(function(msg) {
if (typeof msg !== 'object') {
return msg;
}
try {
return JSON.stringify(msg, null, 2);
} catch(error) {
return _.toString(msg);
}
});
con.innerText += new Date().toLocaleString() + ' ' + msgList.join('') + '\n';
scrollToBottom();
console.log.apply(null, arguments);
};
// 不重要的代码,仅展示使用 - end
window.onload = function () {
// init
const socket = io('/io', {
// 实际使用中可以在这里传递参数
query: {
room: 'demo',
userId: `client_${Math.random()}`,
},
transports: ['websocket']
});
socket.on('connect', () => {
const id = socket.id;
log('#connect,', id, socket);
// 监听自身 id 以实现 p2p 通讯
socket.on(id, msg => {
log('#receive,', msg);
});
});
// 接收在线用户信息
socket.on('online', msg => {
log('#online,', msg);
});
// 系统事件
socket.on('disconnect', msg => {
log('#disconnect', msg);
});
socket.on('disconnecting', () => {
log('#disconnecting');
});
socket.on('error', () => {
log('#error');
});
window.socket = socket;
};
</script>
</body>
</html>
最终客户端连接效果:

补充
实践中声明的 .d.ts 文件, 用于 Typescript 智能提示。
// typings/index.d.ts
import 'egg';
import { Socket, Server as SocketServer, Namespace as SocketNameSpace, Adapter } from 'socket.io';
// import 'egg-socket.io' 可以使用这替代
import 'egg-redis' // 导入 egg-redis 插件的智能提示
import NspController from '../app/io/controller/nsp'
declare module 'egg' {
export interface Application {
io: EggIOServer & EggSocketIO & EggSocketNameSpace;
}
interface Context {
socket: Socket;
}
interface EggIOServer extends SocketServer {
of(nsp: string): EggSocketNameSpace;
}
interface EggSocketIO {
middleware: CustomMiddleware;
controller: CustomController;
}
interface EggSocketNameSpace extends SocketNameSpace {
route(event: string, handler: Function): any;
adapter: MyAdapter;
}
interface MyAdapter extends Adapter {
clients(rooms, callback: Function);
remoteDisconnect(id, isBoolean, error: Function);
}
interface CustomMiddleware {}
interface CustomController {
nsp: NspController;
}
}
本文介绍了如何在Eggjs项目中使用Socket.IO进行实时通信,包括安装配置、使用redis、基本使用方法、控制器和中间件的设置,以及如何处理命名空间和事件。文章还提到了在Cluster模式下启用sticky支持的重要性,并提供了前端连接的示例。
 Socket.IO实践&spm=1001.2101.3001.5002&articleId=105561842&d=1&t=3&u=641ecf74d1db47888aef12c438987f5f)
970

被折叠的 条评论
为什么被折叠?



