Egg Socket.IO

2020-02-06 14:12 更新

Socket.IO 是一個(gè)基于 Node.js 的實(shí)時(shí)應(yīng)用程序框架,在即時(shí)通訊、通知與消息推送,實(shí)時(shí)分析等場(chǎng)景中有較為廣泛的應(yīng)用。

WebSocket 的產(chǎn)生源于 Web 開(kāi)發(fā)中日益增長(zhǎng)的實(shí)時(shí)通信需求,對(duì)比基于 http 的輪詢方式,它大大節(jié)省了網(wǎng)絡(luò)帶寬,同時(shí)也降低了服務(wù)器的性能消耗; socket.io 支持 websocket、polling 兩種數(shù)據(jù)傳輸方式以兼容瀏覽器不支持 WebSocket 場(chǎng)景下的通信需求。

框架提供了 egg-socket.io 插件,增加了以下開(kāi)發(fā)規(guī)約:

  • namespace: 通過(guò)配置的方式定義 namespace(命名空間)
  • middleware: 對(duì)每一次 socket 連接的建立/斷開(kāi)、每一次消息/數(shù)據(jù)傳遞進(jìn)行預(yù)處理
  • controller: 響應(yīng) socket.io 的 event 事件
  • router: 統(tǒng)一了 socket.io 的 event 與 框架路由的處理配置方式

安裝 egg-socket.io

安裝

$ npm i egg-socket.io --save

開(kāi)啟插件:

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

配置

// {app_root}/config/config.${env}.js
exports.io = {
init: { }, // passed to engine.io
namespace: {
'/': {
connectionMiddleware: [],
packetMiddleware: [],
},
'/example': {
connectionMiddleware: [],
packetMiddleware: [],
},
},
};
命名空間為 / 與 /example, 不是 example

uws

Egg Socket 內(nèi)部默認(rèn)使用 ws 引擎,uws 因?yàn)?a href="http://m.hgci.cn/targetlink?url=https://github.com/socketio/socket.io/issues/3319" target="_blank">某些原因被廢止。

如堅(jiān)持需要使用,請(qǐng)按照以下配置即可:

// {app_root}/config/config.${env}.js
exports.io = {
init: { wsEngine: 'uws' }, // default: ws
};

redis

egg-socket.io 內(nèi)置了 socket.io-redis,在 cluster 模式下,使用 redis 可以較為簡(jiǎn)單的實(shí)現(xiàn) clients/rooms 等信息共享

// {app_root}/config/config.${env}.js
exports.io = {
redis: {
host: { redis server host },
port: { redis server port },
auth_pass: { redis server password },
db: 0,
},
};
開(kāi)啟 redis 后,程序在啟動(dòng)時(shí)會(huì)嘗試連接到 redis 服務(wù)器 此處 redis 僅用于存儲(chǔ)連接實(shí)例信息,參見(jiàn) #server.adapter

注意: 如果項(xiàng)目中同時(shí)使用了 egg-redis, 請(qǐng)單獨(dú)配置,不可共用。

部署

框架是以 Cluster 方式啟動(dòng)的,而 socket.io 協(xié)議實(shí)現(xiàn)需要 sticky 特性支持,否則在多進(jìn)程模式下無(wú)法正常工作。

由于 socket.io 的設(shè)計(jì),在多進(jìn)程中服務(wù)器必須在 sticky 模式下工作,故需要給 startCluster 傳遞 sticky 參數(shù)。

修改 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

開(kāi)啟 egg-socket.io 的項(xiàng)目目錄結(jié)構(gòu)如下:

chat
├── app
│   ├── extend
│   │   └── helper.js
│   ├── io
│   │   ├── controller
│   │   │   └── default.js
│   │   └── middleware
│   │   ├── connection.js
│   │   └── packet.js
│   └── router.js
├── config
└── package.json
注意:對(duì)應(yīng)的文件都在 app/io 目錄下

Middleware

中間件有如下兩種場(chǎng)景:

  • Connection
  • Packet

其配置于各個(gè)命名空間下,根據(jù)上述兩種場(chǎng)景分別發(fā)生作用。

注意:

如果我們啟用了框架中間件,則會(huì)發(fā)現(xiàn)項(xiàng)目中有以下目錄:

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

區(qū)別:

  • 框架中間件基于 http 模型設(shè)計(jì),處理 http 請(qǐng)求。
  • 插件中間件基于 socket 模型設(shè)計(jì),處理 socket.io 請(qǐng)求。

雖然框架通過(guò)插件盡量統(tǒng)一了它們的風(fēng)格,但務(wù)必注意,它們的使用場(chǎng)景是不一樣的。詳情參見(jiàn) issue:#1416

Connection

在每一個(gè)客戶端連接或者退出時(shí)發(fā)生作用,故而我們通常在這一步進(jìn)行授權(quán)認(rèn)證,對(duì)認(rèn)證失敗的客戶端做出相應(yīng)的處理

// {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!');
};
};

踢出用戶示例:

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

同時(shí),針對(duì)當(dāng)前的連接也可以簡(jiǎn)單處理:

// {app_root}/app/io/middleware/connection.js
module.exports = app => {
return async (ctx, next) => {
if (true) {
ctx.socket.disconnect();
return;
}
await next();
console.log('disconnection!');
};
};

Packet

作用于每一個(gè)數(shù)據(jù)包(每一條消息);在生產(chǎn)環(huán)境中,通常用于對(duì)消息做預(yù)處理,又或者是對(duì)加密消息的解密等操作

// {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 對(duì)客戶端發(fā)送的 event 進(jìn)行處理;由于其繼承于 egg.Contoller, 擁有如下成員對(duì)象:

  • 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 functions

exports.ping = async function() {
const message = this.args[0];
await this.socket.emit('res', `Hi! I've got your message: ${message}`);
};

Router

路由負(fù)責(zé)將 socket 連接的不同 events 分發(fā)到對(duì)應(yīng)的 controller,框架統(tǒng)一了其使用方式

// {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 有如下的系統(tǒng)事件:

  • disconnecting doing the disconnect
  • disconnect connection has disconnected.
  • error Error occurred

Namespace/Room

Namespace (nsp)

namespace 通常意味分配到不同的接入點(diǎn)或者路徑,如果客戶端沒(méi)有指定 nsp,則默認(rèn)分配到 "/" 這個(gè)默認(rèn)的命名空間。

在 socket.io 中我們通過(guò) of 來(lái)劃分命名空間;鑒于 nsp 通常是預(yù)定義且相對(duì)固定的存在,框架將其進(jìn)行了封裝,采用配置的方式來(lái)劃分不同的命名空間。

// 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 中,通過(guò) join/leave 方法來(lái)加入或者離開(kāi); 框架中使用方法相同;

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!');
};
};

注意: 每一個(gè) socket 連接都會(huì)擁有一個(gè)隨機(jī)且不可預(yù)測(cè)的唯一 id Socket#id,并且會(huì)自動(dòng)加入到以這個(gè) id 命名的 room 中

實(shí)例

這里我們使用 egg-socket.io 來(lái)做一個(gè)支持 p2p 聊天的小例子

client

UI 相關(guān)的內(nèi)容不重復(fù)寫(xiě)了,通過(guò) window.socket 調(diào)用即可

// browser
const log = console.log;

window.onload = function() {
// init
const socket = io('/', {

// 實(shí)際使用中可以在這里傳遞參數(shù)
query: {
room: 'demo',
userId: `client_${Math.random()}`,
},

transports: ['websocket']
});

socket.on('connect', () => {
const id = socket.id;

log('#connect,', id, socket);

// 監(jiān)聽(tīng)自身 id 以實(shí)現(xiàn) p2p 通訊
socket.on(id, msg => {
log('#receive,', msg);
});
});

// 接收在線用戶信息
socket.on('online', msg => {
log('#online,', msg);
});

// 系統(tǒng)事件
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 的上層封裝,故我們無(wú)法直接用小程序的 API 連接,可以使用類似 weapp.socket.io 的庫(kù)來(lái)適配。

示例代碼如下:

// 小程序端示例代碼
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', {
title: 'this is a news'
})

server

以下是 demo 的部分代碼并解釋了各個(gè)方法的作用

config

// {app_root}/config/config.${env}.js
exports.io = {
namespace: {
'/': {
connectionMiddleware: [ 'auth' ],
packetMiddleware: [ ], // 針對(duì)消息的處理暫時(shí)不實(shí)現(xiàn)
},
},

// cluster 模式下,通過(guò) redis 實(shí)現(xiàn)數(shù)據(jù)共享
redis: {
host: '127.0.0.1',
port: 6379,
},
};

// 可選
exports.redis = {
client: {
port: 6379,
host: '127.0.0.1',
password: '',
db: 0,
},
};

helper

框架擴(kuò)展用于封裝數(shù)據(jù)格式

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

module.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 中間件負(fù)責(zé) socket 連接的處理

// {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);

// 踢出用戶前發(fā)送消息
socket.emit(id, helper.parseMsg('deny', msg));

// 調(diào)用 adapter 方法踢出用戶,客戶端觸發(fā) disconnect 事件
nsp.adapter.remoteDisconnect(id, true, err => {
logger.error(err);
});
};

// 檢查房間是否存在,不存在則踢出用戶
// 備注:此處 app.redis 與插件無(wú)關(guān),可用其他存儲(chǔ)代替
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();

// 用戶離開(kāi)
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 通信,通過(guò) exchange 進(jìn)行數(shù)據(jù)交換

// {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

// {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);
};

開(kāi)兩個(gè) tab 頁(yè)面,并調(diào)出控制臺(tái):

socket.emit('exchange', {
target: 'Dkn3UXSu8_jHvKBmAAHW',
payload: {
msg : 'test',
},
});

參考鏈接


以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)