Socket.IO 是一個基于 Node.js 的實時應用程序框架,在即時通訊、通知與消息推送,實時分析等場景中有較為廣泛的應用。
WebSocket 的產(chǎn)生源于 Web 開發(fā)中日益增長的實時通信需求,對比基于 http 的輪詢方式,它大大節(jié)省了網(wǎng)絡帶寬,同時也降低了服務器的性能消耗; socket.io 支持 websocket、polling 兩種數(shù)據(jù)傳輸方式以兼容瀏覽器不支持 WebSocket 場景下的通信需求。
框架提供了 egg-socket.io 插件,增加了以下開發(fā)規(guī)約:
- namespace: 通過配置的方式定義 namespace(命名空間)
- middleware: 對每一次 socket 連接的建立/斷開、每一次消息/數(shù)據(jù)傳遞進行預處理
- controller: 響應 socket.io 的 event 事件
- router: 統(tǒng)一了 socket.io 的 event 與 框架路由的處理配置方式
安裝 egg-socket.io
安裝
$ npm i egg-socket.io --save
|
開啟插件:
// {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)部默認使用 ws 引擎,uws 因為某些原因被廢止。
如堅持需要使用,請按照以下配置即可:
// {app_root}/config/config.${env}.js exports.io = { init: { wsEngine: 'uws' }, // default: ws };
|
redis
egg-socket.io 內(nèi)置了 socket.io-redis,在 cluster 模式下,使用 redis 可以較為簡單的實現(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, }, };
|
開啟 redis 后,程序在啟動時會嘗試連接到 redis 服務器 此處 redis 僅用于存儲連接實例信息,參見 #server.adapter
注意: 如果項目中同時使用了 egg-redis, 請單獨配置,不可共用。
部署
框架是以 Cluster 方式啟動的,而 socket.io 協(xié)議實現(xiàn)需要 sticky 特性支持,否則在多進程模式下無法正常工作。
由于 socket.io 的設計,在多進程中服務器必須在 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
開啟 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
中間件有如下兩種場景:
其配置于各個命名空間下,根據(jù)上述兩種場景分別發(fā)生作用。
注意:
如果我們啟用了框架中間件,則會發(fā)現(xiàn)項目中有以下目錄:
- app/middleware:框架中間件
- app/io/middleware:插件中間件
區(qū)別:
- 框架中間件基于 http 模型設計,處理 http 請求。
- 插件中間件基于 socket 模型設計,處理 socket.io 請求。
雖然框架通過插件盡量統(tǒng)一了它們的風格,但務必注意,它們的使用場景是不一樣的。詳情參見 issue:#1416
Connection
在每一個客戶端連接或者退出時發(fā)生作用,故而我們通常在這一步進行授權認證,對認證失敗的客戶端做出相應的處理
// {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); }); };
|
同時,針對當前的連接也可以簡單處理:
// {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
作用于每一個數(shù)據(jù)包(每一條消息);在生產(chǎn)環(huán)境中,通常用于對消息做預處理,又或者是對加密消息的解密等操作
// {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 對客戶端發(fā)送的 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 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 分發(fā)到對應的 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 通常意味分配到不同的接入點或者路徑,如果客戶端沒有指定 nsp,則默認分配到 "/" 這個默認的命名空間。
在 socket.io 中我們通過 of 來劃分命名空間;鑒于 nsp 通常是預定義且相對固定的存在,框架將其進行了封裝,采用配置的方式來劃分不同的命名空間。
// 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 方法來加入或者離開; 框架中使用方法相同;
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 相關的內(nèi)容不重復寫了,通過 window.socket 調(diào)用即可
// browser const log = console.log;
window.onload = function() { // init const socket = io('/', {
// 實際使用中可以在這里傳遞參數(shù) query: { room: 'demo', userId: `client_${Math.random()}`, },
transports: ['websocket'] });
socket.on('connect', () => { const id = socket.id;
log('#connect,', id, socket);
// 監(jiān)聽自身 id 以實現(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 的上層封裝,故我們無法直接用小程序的 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', { title: 'this is a news' })
|
server
以下是 demo 的部分代碼并解釋了各個方法的作用
config
// {app_root}/config/config.${env}.js exports.io = { namespace: { '/': { connectionMiddleware: [ 'auth' ], packetMiddleware: [ ], // 針對消息的處理暫時不實現(xiàn) }, },
// cluster 模式下,通過 redis 實現(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
框架擴展用于封裝數(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 中間件負責 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 與插件無關,可用其他存儲代替 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 進行數(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); };
|
開兩個 tab 頁面,并調(diào)出控制臺:
socket.emit('exchange', { target: 'Dkn3UXSu8_jHvKBmAAHW', payload: { msg : 'test', }, });
|
參考鏈接
更多建議: