Eggjs学习系列(六) Socket.IO实践

本文介绍了如何在Eggjs项目中使用Socket.IO进行实时通信,包括安装配置、使用redis、基本使用方法、控制器和中间件的设置,以及如何处理命名空间和事件。文章还提到了在Cluster模式下启用sticky支持的重要性,并提供了前端连接的示例。

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 有如下的系统事件:

  • disconnecting doing the disconnect
  • disconnect connection has disconnected.
  • error Error 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;
    }
}

参考资料

Socket.IO

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值