- Socket.IO
- 安装 egg-socket.io
- 安装
- 配置
- uws
- redis
- 部署
- 使用 egg-socket.io
- Middleware
- Connection
- Packet
- Controller
- Router
- Namespace/Room
- Namespace (nsp)
- Room
- Middleware
- 实例
- client
- 微信小程序
- server
- config
- helper
- middleware
- controller
- router
- client
- 参考链接
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
安装
$ npm i egg-socket.io --save
开启插件:
// {app_root}/config/plugin.jsexports.io = {enable: true,package: 'egg-socket.io',};
配置
// {app_root}/config/config.${env}.jsexports.io = {init: { }, // passed to engine.ionamespace: {'/': {connectionMiddleware: [],packetMiddleware: [],},'/example': {connectionMiddleware: [],packetMiddleware: [],},},};
命名空间为
/与/example, 不是example
uws
Egg Socket 内部默认使用 ws 引擎,uws 因为某些原因被废止。
如坚持需要使用,请按照以下配置即可:
// {app_root}/config/config.${env}.jsexports.io = {init: { wsEngine: 'uws' }, // default: ws};
redis
egg-socket.io 内置了 socket.io-redis,在 cluster 模式下,使用 redis 可以较为简单的实现 clients/rooms 等信息共享
// {app_root}/config/config.${env}.jsexports.io = {redis: {host: { redis server host },port: { redis server port },auth_pass: { redis server password },db: 0,},};
开启
redis后,程序在启动时会尝试连接到 redis 服务器此处redis仅用于存储连接实例信息,参见 #server.adapter
注意:如果项目中同时使用了 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 的项目目录结构如下:
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
Connection
在每一个客户端连接或者退出时发生作用,故而我们通常在这一步进行授权认证,对认证失败的客户端做出相应的处理
// {app_root}/app/io/middleware/connection.jsmodule.exports = app => {return async (ctx, next) => {ctx.socket.emit('res', 'connected!');await next();// execute when disconnect.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_root}/app/io/middleware/connection.jsmodule.exports = app => {return async (ctx, next) => {if (true) {ctx.socket.disconnet();return;}await next();console.log('disconnection!');};};
Packet
作用于每一个数据包(每一条消息);在生产环境中,通常用于对消息做预处理,又或者是对加密消息的解密等操作
// {app_root}/app/io/middleware/packet.jsmodule.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 文档
// {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 functionsexports.ping = async function() {const message = this.args[0];await this.socket.emit('res', `Hi! I've got your message: ${message}`);};
Router
路由负责将 socket 连接的不同 events 分发到对应的 controller,框架统一了其使用方式
// {app_root}/app/router.jsmodule.exports = app => {const { router, controller, io } = app;// defaultrouter.get('/', controller.home.index);// socket.ioio.of('/').route('server', io.controller.home.server);};
注意:
nsp 有如下的系统事件:
disconnectingdoing the disconnectdisconnectconnection has disconnected.errorError occurred
Namespace/Room
Namespace (nsp)
namespace 通常意味分配到不同的接入点或者路径,如果客户端没有指定 nsp,则默认分配到 “/“ 这个默认的命名空间。
在 socket.io 中我们通过 of 来划分命名空间;鉴于 nsp 通常是预定义且相对固定的存在,框架将其进行了封装,采用配置的方式来划分不同的命名空间。
// socket.iovar nsp = io.of('/my-namespace');nsp.on('connection', function(socket){console.log('someone connected');});nsp.emit('hi', 'everyone!');// eggexports.io = {namespace: {'/': {connectionMiddleware: [],packetMiddleware: [],},},};
Room
room 存在于 nsp 中,通过 join/leave 方法来加入或者离开; 框架中使用方法相同;
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 调用即可
// browserconst log = console.log;window.onload = function() {// initconst socket = 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;};
微信小程序
微信小程序提供的 API 为 WebSocket ,而 socket.io 是 Websocket 的上层封装,故我们无法直接用小程序的 API 连接,可以使用类似 weapp.socket.io 的库来适配。
示例代码如下:
// 小程序端示例代码const io = require('./yout_path/weapp.socket.io.js')const socket = io('http://localhost:8000')socket.on('connect', function () {console.log('connected')});socket.on('news', d => {console.log('received news: ', d)})socket.emit('news', {'this is a news'})
server
以下是 demo 的部分代码并解释了各个方法的作用
config
// {app_root}/config/config.${env}.jsexports.io = {namespace: {'/': {connectionMiddleware: [ 'auth' ],packetMiddleware: [ ], // 针对消息的处理暂时不实现},},// cluster 模式下,通过 redis 实现数据共享redis: {host: '127.0.0.1',port: 6379,},};// 可选exports.redis = {client: {port: 6379,host: '127.0.0.1',password: '',db: 0,},};
helper
框架扩展用于封装数据格式
// {app_root}/app/extend/helper.jsmodule.exports = {parseMsg(action, payload = {}, metadata = {}) {const meta = Object.assign({}, {timestamp: Date.now(),}, metadata);return {meta,data: {action,payload,},};},};
Format:
{data: {action: 'exchange', // 'deny' || 'exchange' || 'broadcast'payload: {},},meta:{timestamp: 1512116201597,client: 'nNx88r1c5WuHf9XuAAAB',target: 'nNx88r1c5WuHf9XuAAAB'},}
middleware
egg-socket.io 中间件负责 socket 连接的处理
// {app_root}/app/io/middleware/auth.jsconst 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;}// 用户加入logger.debug('#join', room);socket.join(room);// 在线列表nsp.adapter.clients(rooms, (err, clients) => {logger.debug('#online_join', clients);// 更新在线用户列表nsp.to(room).emit('online', {clients,action: 'join',target: 'participator',message: `User(${id}) joined.`,});});await next();// 用户离开logger.debug('#leave', room);// 在线列表nsp.adapter.clients(rooms, (err, clients) => {logger.debug('#online_leave', clients);// 获取 client 信息// const clientsDetail = {};// clients.forEach(client => {// const _client = app.io.sockets.sockets[client];// const _query = _client.handshake.query;// clientsDetail[client] = _query;// });// 更新在线用户列表nsp.to(room).emit('online', {clients,action: 'leave',target: 'participator',message: `User(${id}) leaved.`,});});};};
controller
P2P 通信,通过 exchange 进行数据交换
// {app_root}/app/io/controller/nsp.jsconst 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
// {app_root}/app/router.jsmodule.exports = app => {const { router, controller, io } = app;router.get('/', controller.home.index);// socket.ioio.of('/').route('exchange', io.controller.nsp.exchange);};
开两个 tab 页面,并调出控制台:
socket.emit('exchange', {target: 'Dkn3UXSu8_jHvKBmAAHW',payload: {msg : 'test',},});

参考链接
- socket.io
- egg-socket.io
- egg-socket.io example
- egg-socket.io demo
- nginx proxy_bind
