Midway 实时通信实战:使用 @midwayjs/socketio 组件构建 Socket.io 实时双向通信服务

原创2026-10-08 15:17:17326 阅读
文章标签:后端微服务云原生

Midway 实时通信实战:使用 @midwayjs/socketio 组件构建 Socket.io 实时双向通信服务

本篇指南以 Midway 框架中的 @midwayjs/socketio 组件为核心,系统讲解如何在 Midway 项目中创建基于 Socket.io v4 的实时双向通信服务,涵盖组件安装、框架接入、控制器与事件收发、多层中间件体系、本地测试、房间广播、分布式 Adapter、Sticky Session 部署以及常见故障排查。读完本文,你将能够独立搭建一个生产可用的 Socket.io 服务,并将其与 Koa 等 Web 框架、多进程集群部署方案无缝结合。

Socket.io 是什么,Midway 如何支持它

Socket.io 是业界常用的实时通信库,用于在浏览器与服务器之间建立实时、双向、基于事件的通信通道。Midway 对 Socket.io 提供了完整的封装与支持,可以简单地创建一个 Socket.io 服务。本文演示了在 Midway 体系下如何提供 Socket.io 服务。

Midway 基于最新的 Socket.io v4),Node.js 版本要求为 >=20。

提供能力一览

描述 是否支持
可用于标准项目 ✅
可用于 Serverless ❌
可用于一体化(integration) ✅
包含独立主框架 ✅
包含独立日志 ❌

从源码结构看,@midwayjs/socketio 是一个完整的、可独立启动的 Midway 主框架(MidwaySocketIOFramework),因此它既可以作为主框架单独运行,也可以作为子框架挂在 Koa 等其他主框架之下(参见 packages/socketio/src/framework.ts)。

安装依赖

在已有项目中安装 Socket.io 相关依赖:

$ npm i @midwayjs/socketio@4 --save
## 可选依赖(客户端测试用)
$ npm i @types/socket.io-client socket.io-client --save-dev

也可以在 package.json 中直接声明以下依赖:

{
  "dependencies": {
    "@midwayjs/socket.io": "^4.0.0",
    // 客户端可选
    "socket.io-client": "^4.4.1",
    // ...
  },
  "devDependencies": {
    // 客户端可选
    "@types/socket.io-client": "^1.4.36",
    // ...
  }
}

注意:socket.io-client 及其类型声明仅用于编写本地测试客户端,属于可选项;服务端运行时只依赖 @midwayjs/socketio(其内部依赖 socket.io)。

开启组件

@midwayjs/socketio 可以作为独立的主框架使用:

import { Configuration } from '@midwayjs/core';
import * as socketio from '@midwayjs/socketio';

@Configuration({
  imports: [socketio]
  // ...
})
export class MainConfiguration {
  async onReady() {
    // ...
  }
}

也可以挂载在其他主框架下,例如与 @midwayjs/koa 组合:

import { Configuration } from '@midwayjs/core';
import * as koa from '@midwayjs/koa';
import * as socketio from '@midwayjs/socketio';

@Configuration({
  imports: [koa, socketio]
  // ...
})
export class MainConfiguration {
  async onReady() {
    // ...
  }
}

组件接入的底层机制

从源码实现来看,组件接入有两个关键点(见 packages/socketio/src/configuration.ts 与 packages/socketio/src/framework.ts):

  • 组件声明了命名空间 socketIO,这正是 @App('socketIO') 注入 key 的来源;
  • 框架的 run() 方法在启动时按如下逻辑工作:
    • 先通过 loadMidwayController() 扫描所有带 @WSController 装饰器的模块,逐个建立 Namespace;
    • 如果配置了 adapter,调用 this.app.adapter(adapter) 注册分布式适配器;
    • 如果配置了 port(数字类型),则为该端口创建独立 HTTP 服务并 listen;
    • 否则若容器中已存在 HTTP_SERVER_KEY(即主 Web 框架的 HTTP Server),则调用 this.app.attach(httpServer) 挂载到现有 Web 服务上。

目录结构

下面是一个 Socket.io 项目的基础目录结构。与传统应用类似,我们创建了一个 socket 目录来存放 Socket.io 的服务代码:

.
├── package.json
├── src
│   ├── configuration.ts          ## 入口配置文件
│   ├── interface.ts
│   └── socket                  	## socket.io 服务文件
│       └── hello.controller.ts
├── test
├── bootstrap.js                  ## 服务启动入口
└── tsconfig.json

在仓库的测试夹具中可以看到同样的组织方式,例如 packages/socketio/test/fixtures/base-app/src/socket/api.ts 将控制器放在 src/socket 目录下,并通过 @WSController('/') 绑定到默认命名空间。

Socket.io 的工作原理

Socket.io 服务器与 Socket.io 客户端(浏览器、Node.js 或其他编程语言)之间的双向通道是通过 WebSocket 连接建立的;当 WebSocket 不可用时,HTTP 长轮询(HTTP long polling)将作为备用手段。

Socket.io 代码构建在 Engine.io 库之上,属于 Engine.io 的上层实现:

  • Engine.io 负责整个服务器与客户端之间的连接,包括连接校验、传输方式(Transports)等;
  • Socket.io 负责上层特性,如重连(reconnection)、报文缓冲(packet buffering)、广播(broadcasting)等。

Socket.io(Engine.io)实现了两种 Transports(传输模式):

  1. HTTP 长轮询:HTTP GET 请求用于长时间运行(长连接),POST 请求用于短时运行(短连接)。
  2. WebSocket 协议:直接基于 WebSocket Connection 实现,提供服务器与客户端之间的双向、低延迟通信通道。

默认情况下,Socket.io 会先用 HTTP 长轮询建立连接,并发送一段类似如下结构的数据:

{
  "sid": "FSDjX-WRwSA4zTZMALqx", // 连接的会话 id
  "upgrades": ["WebSocket"], // 可升级的协议
  "pingInterval": 2500, // 心跳间隔(ms)
  "pingTimeout": 20000 // 心跳超时(ms)
}

当当前服务满足升级到 WebSocket 协议的条件时,连接会自动升级到 WebSocket 协议,升级过程如下:

    1. 第一次握手,传输 sid 等结构;
    1. 使用 HTTP 长轮询发送数据;
    1. 使用 HTTP 长轮询返回数据;
    1. 升级协议,改用 WebSocket 协议发送数据;
    1. 协议升级完成后,关闭之前的长轮询。

此后便进入正常的 WebSocket 通信阶段。

Socket 服务(控制器)

Midway 通过 @WSController 装饰器定义 Socket 服务:

@WSController('/')
export class HelloController {
  // ...
}

@WSController 的参数指的是每个 Socket 的 Namespace(命名空间,而非路径)。如果不提供 Namespace,Socket.io 会自动创建一个 / Namespace,并将所有客户端连接放入其中。

:::info 这里的命名空间支持字符串和正则表达式两种形式。 :::

从源码实现看,WSController 装饰器(packages/core/src/decorator/ws/webSocketController.ts)的签名是 WSController(namespace: string | RegExp = '/', routerOptions),并做了三件事:

  • 将模块注册到 WS_CONTROLLER_KEY 元数据中,供框架扫描;
  • 保存 namespace 与路由选项(middleware、connectionMiddleware);
  • 自动给控制器加上 Request 作用域与 Provide 标记,使其成为请求作用域的 IoC 对象。

仓库测试夹具 packages/socketio/test/fixtures/base-app-namespace-regexp/src/socket/api.ts 演示了正则命名空间的用法:@WSController(/^\/abc.+/),测试中客户端连接到 /abc123 命名空间即可命中。

监听连接事件

当 Namespace 有客户端连接时,会触发 connection 事件。我们可以用 @OnWSConnection() 装饰器修饰一个方法,当每个客户端首次连接到该 Namespace 时,该方法会被自动调用:

import { WSController, OnWSConnection, Inject } from '@midwayjs/core';
import { Context } from '@midwayjs/socketio';

@WSController('/')
export class HelloSocketController {

  @Inject()
  ctx: Context;

  @OnWSConnection()
  async onConnectionMethod() {
    console.log('on client connect', this.ctx.id);
  }
}

:::info 这里的 ctx 等价于 socket 实例。 :::

断连事件

与连接事件对应,@OnWSDisConnection() 装饰器用于监听客户端断开:

@OnWSDisConnection()
async onDisconnect(reason: string) {
  console.log('client disconnect, reason:', reason);
}

从 packages/core/src/decorator/ws/webSocketEvent.ts 的枚举可以看到,Midway 对 Socket.io 生命周期事件做了完整的类型化建模:ON_CONNECTION、ON_DISCONNECTION、ON_MESSAGE、ON_SOCKET_ERROR、EMIT、BROADCAST。框架层(packages/socketio/src/framework.ts)在处理 connect 事件时,会先执行连接中间件与 @OnWSConnection 方法,再注册消息与断连监听,确保后续事件监听能按序处理。

消息与响应

Socket.io 通过监听事件来获取数据。Midway 提供 @OnWSMessage() 装饰器来格式化接收的事件,每次客户端发送事件时,被修饰的方法都会被执行:

import { WSController, Provide, OnWSMessage, Inject } from '@midwayjs/core';
import { Context } from '@midwayjs/socketio';

@WSController('/')
export class HelloSocketController {

  @Inject()
  ctx: Context;

  @OnWSMessage('myEvent')
  async gotMessage(data) {
    console.log('on data got', this.ctx.id, data);
  }
}

注意:由于 Socket.io 可以在一个事件中传递多个数据,这里的参数可以是多个:

  @OnWSMessage('myEvent')
  async gotMessage(data1, data2, data3) {
    // ...
  }

获取数据后,通过业务逻辑处理数据,然后将结果返回给客户端。返回时,我们也通过另一个事件将结果发送给客户端。

@WSEmit 装饰器会把方法的返回值发送给客户端:

import { WSController, OnWSConnection, Inject } from '@midwayjs/core';
import { Context } from '@midwayjs/socketio';

@WSController('/')
export class HelloSocketController {

  @Inject()
  ctx: Context;

  @OnWSMessage('myEvent')
  @WSEmit('myEventResult')
  async gotMessage() {
    return 'hello world'; // 这里将 hello world 字符串返回给客户端
  }
}

上述代码中,方法返回 hello world,该值会被自动发送到客户端监听的 myEventResult 事件。

装饰器源码层面的补充

@OnWSMessage(eventName, eventOptions?) 与 @WSEmit(messageName, roomName?) 的实现位于 packages/core/src/decorator/ws/webSocketEvent.ts:

  • @WSEmit 除了指定返回事件名,还支持第二个参数 roomName(字符串或字符串数组),表示将结果定向发送到指定房间(框架会通过 socket.to(roomName) 链式转发,见 packages/socketio/src/framework.ts 中的 bindSocketResponse);
  • 框架收到消息后,若最后一个参数是函数(ack 回调),会直接调用回调返回结果;否则通过 bindSocketResponse 匹配方法对应的 EMIT/BROADCAST 响应事件并发送;
  • 仓库提供了 @WSBroadCast(messageName, roomName?) 装饰器用于广播响应,而 OnMessage、Emit、OnConnection、OnDisConnection 等旧命名作为已弃用别名保留(见 webSocketEvent.ts 底部)。

Socket 中间件

Socket 中的中间件编写方式与 Web 中间件类似(参见 Web 中间件),但加载时机略有不同。

由于 Socket 有“连接”和“接收消息”两个阶段,中间件被划分为以下几类:

  • 全局 Connection 中间件:对所有命名空间下的连接生效;
  • 全局 Message 中间件:对命名空间下的所有消息生效;
  • Controller 中间件:对单个命名空间下的连接和消息生效;
  • Connection 中间件:对单个命名空间下的连接生效;
  • Message 中间件:对单个命名空间下的消息生效。

中间件的编写

注意:中间件必须通过 return 返回结果。

// src/middleware/socket.middleware.ts
import { Middleware } from '@midwayjs/core';
import { Context, NextFunction } from '@midwayjs/socketio';

@Middleware()
export class SocketMiddleware {
  resolve() {
    return async (ctx: Context, next: NextFunction) => {
      // ...
      return await next();
    }
  }
}

全局中间件

与 Web 中间件类似,通过 socket.io 的 app 实例注册全局中间件:

import * as socketio from '@midwayjs/socketio';

@Configuration({
  imports: [
    socketio
  ],
  // ...
})
export class MainConfiguration {

  @App('socketIO')
  app: Application;

  async onReady() {
    // 注册全局 Connection 中间件
    this.app.useConnectionMiddleware(SocketMiddleware);
    // 也可以注册全局 Message 中间件
    this.app.useMiddleware(SocketMiddleware);
  }
}

命名空间内的中间件

通过装饰器,可以注册不同阶段的中间件。

例如 Namespace 级中间件,对单个命名空间下的连接和消息生效:

// ...

// Namespace 级中间件
@WSController('/api', { middleware: [SocketMiddleware]})
export class APIController {
}

Connection 中间件,在连接时生效:

// ...

@WSController('/api')
export class APIController {

  // 触发 Connection 时的中间件
  @OnWSConnection({
    middleware: [SocketMiddleware]
  })
  init() {
    // ...
  }
}

Message 中间件,在收到特定消息时生效:

// ...

@WSController('/api')
export class APIController {

  // 触发 Message 时的中间件
  @OnWSMessage('my', {
    middleware: [SocketMiddleware]
  })
  @WSEmit('ok')
  async gotMyMessage() {
    // ...
  }
}

中间件执行顺序(源码佐证)

在 packages/socketio/src/framework.ts 中可以看到中间件的组合顺序:

  • 连接阶段:全局 Connection 中间件 → Controller 级 connectionMiddleware → @OnWSConnection 方法自身的 middleware;
  • 消息阶段:applyMiddleware 组合全局 Message 中间件 → Controller 级 middleware → @OnWSMessage 方法自身的 middleware → 控制器方法。

测试夹具 packages/socketio/test/fixtures/base-app-middleware/src/middleware/conn.middleware.ts 通过多个中间件对 ctx 中的 result 属性做累加(全局连接中间件 +1、全局消息中间件 +2、控制器中间件 +3、命名空间连接中间件 +4、命名空间消息中间件 +5),并在 packages/socketio/test/index.test.ts 中断言:默认命名空间下结果为 3(1+2),/api 命名空间下连接 + 消息结果为 15(1+2+3+4+5),叠加方法级中间件后为 26,精确验证了各级中间件的执行顺序与叠加效果。

本地测试

由于 socket.io 框架可以独立启动(挂载到默认 HTTP 服务,或与其他 Midway 框架一起使用),测试时需要注意端口配置。

当作为独立框架启动时,需要指定端口:

// src/config/config.default
export default {
  // ...
  socketIO: {
    port: 3000
  },
}

当作为子框架启动时(例如与 http 一起,因为 http 在单测时不指定端口——会使用 supertest 自动生成端口,无法很好地测试),只能在测试环境中显式指定一个端口:

// src/config/config.unittest
export default {
  // ...
  koa: {
    port: null,
  },
  socketIO: {
    port: 3000
  },
}

:::tip

    1. 这里的端口只是测试时 WebSocket 服务启动的端口;
    1. koa 中的端口为 null,表示在测试环境中不配置端口时不会启动 http 服务。 :::

与 Midway 其他测试方式一样,我们使用 createApp 启动项目:

import { createApp, close } from '@midwayjs/mock'
// 这里使用的 Framework 定义以主框架为准
import { Framework } from '@midwayjs/koa';

describe('/test/index.test.ts', () => {
  it('should create app and test socket.io', async () => {
    const app = await createApp<Framework>();

    // ...

    await close(app);
  });
});

测试时既可以直接使用 socket.io-client,也可以使用 Midway 提供的、基于 socket.io-client 封装好的测试客户端 createSocketIOClient。

服务端处理逻辑

假设服务端处理逻辑如下(返回客户端传入数据相加的结果):

@OnWSMessage('myEvent')
@WSEmit('myEventResult')
async gotMessage(data1, data2, data3) {
  return {
    name: 'harry',
    result: data1 + data2 + data3
  };
}

测试代码(Promise 写法)

import { createApp, close } from '@midwayjs/mock'
import { Framework } from '@midwayjs/koa';
import { createSocketIOClient } from '@midwayjs/mock';
import { once } from 'events';

describe('/test/index.test.ts', () => {
  it('should test create socket app', async () => {

    // 创建服务
    const app = await createApp<Framework>();

    // 创建对应客户端
    const client = await createSocketIOClient({
      port: 3000
    });

    // 返回结果
    const data = await new Promise(resolve => {
      client.on('myEventResult', resolve);
      // 发送事件
      client.send('myEvent', 1, 2, 3);
    });

    // 断言结果
    expect(data).toEqual({
      name: 'harry',
      result: 6
    });

    // 关闭客户端
    await client.close();
    // 关闭服务
    await close(app);
  });
});

测试代码(events 模块 once 写法)

如果有多个客户端,可以使用 Node 自带的 events 模块的 once 方法优化代码:

import { createApp, close } from '@midwayjs/mock'
import { Framework } from '@midwayjs/koa';
import { createSocketIOClient } from '@midwayjs/mock';
import { once } from 'events';

describe('/test/index.test.ts', () => {

  it('should test create socket app', async () => {

    // 创建服务
    const app = await createApp<Framework>();

    // 创建客户端
    const client = await createSocketIOClient({
      port: 3000
    });

    // 用 promise 方式监听事件
    const gotEvent = once(client, 'myEventResult');
    // 发送事件
    client.send('myEvent', 1, 2, 3);
    // 等待返回
    const [data] = await gotEvent;
    // 断言结果
    expect(data).toEqual({
      name: 'harry',
      result: 6
    });

    // 关闭客户端
    await client.close();
    // 关闭服务
    await close(app);
  });
});

两种写法效果相同,按你的理解习惯任选其一即可。

测试客户端的能力(源码说明)

createSocketIOClient 的封装位于 packages/mock/src/client/socketio.ts,它基于 socket.io-client 提供:

  • connect():等待 connect 事件建立连接后返回;
  • send(eventName, ...args) / emit(eventName, ...args):发送事件;
  • on(eventName, handler) / once(eventName, handler):监听事件;
  • sendWithAck(eventName, ...args):以 ack 回调方式发送并等待返回值;
  • close():关闭连接。

其选项支持 url、protocol(默认 http)、host(默认 127.0.0.1)、port(默认 80)、namespace 以及 socket.io-client 的 ManagerOptions/SocketOptions(如 path)。仓库测试用例 packages/socketio/test/index.test.ts 展示了默认命名空间、ack、多命名空间、房间广播、正则命名空间等多种场景的完整测试写法。

消息等待回执(ack)

Socket.io 支持一种直接返回消息的写法:客户端发送消息时,如果最后一个参数是函数(callback),服务器可以获取这个回调并直接向客户端返回数据,而无需创建新的事件。

我们的服务端代码无需任何改动——@midwayjs/socketio 会判断最后一个参数是否为函数,并自动将结果返回给客户端。

例如服务端代码:

@OnWSMessage('myEvent')
@WSEmit('myEventResult')
async gotMessage(data1, data2, data3) {
  return {
    name: 'harry',
    result: data1 + data2 + data3
  };
}

客户端测试代码:

import { createApp, close } from '@midwayjs/mock'
import { Framework } from '@midwayjs/koa';
import { createSocketIOClient } from '@midwayjs/mock';
import { once } from 'events';

describe('/test/index.test.ts', () => {

  it('should test create socket app', async () => {

    // 创建服务
    const app = await createApp<Framework>();

    // 创建对应客户端
    const client = await createSocketIOClient({
      port: 3000
    });

    // 发送事件,以 await 方式等待回执
    const data = await client.sendWithAck('myEvent', 1, 2, 3);

    // 断言结果
    expect(data).toEqual({
      name: 'harry',
      result: 6
    });

    // 关闭客户端
    await client.close();
    // 关闭服务
    await close(app);
  });
});

源码层面的依据:在 packages/socketio/src/framework.ts 的消息处理分支中,框架在拿到业务方法返回值后,会先判断 typeof args[args.length - 1] === 'function';若为真则直接以 argsargs.length - 1 调用该回调完成 ack 回执,否则才走 bindSocketResponse 的 emit 路径。这也是服务端代码无需区分两种模式的原因。

常用消息与广播

以下是一个完整示例:

import { Context, Application } from '@midwayjs/socketio';
import { WSController, OnWSMessage, WSEmit, App, Inject } from '@midwayjs/core';

@WSController('/')
export class HelloSocketController {

  @Inject()
  ctx: Context;

  @App('socketIO')
  app: Application;

  @OnWSMessage('myEvent')
  @WSEmit('myEventResult')
  async gotMessage() {
    // TODO
  }
}

发送给客户端(或直接以装饰器形式返回)

this.ctx.emit("hello", "can you hear me?", 1, 2, "abc");

发送给除发送者以外的所有客户端

this.ctx.broadcast.emit("broadcast", "hello friends!");

发送给 game 房间内除发送者以外的所有客户端

this.ctx.to("game").emit("nice game", "let's play a game");

发送给 game1 和 game2 房间内除发送者以外的所有客户端

this.ctx.to("game1").to("game2").emit("nice game", "let's play a game (too)");

发送给 game 房间内的所有客户端(包含发送者)

this.app.in("game").emit("big-announcement", "the game will start soon");

广播到 myNamespace 命名空间的所有客户端(包含发送者)

// 从 app 发送
this.app.of("myNamespace").emit("bigger-announcement", "the tournament will start soon");
// 从 ctx 发送
this.ctx.nsp.emit("bigger-announcement", "the tournament will start soon");

发送给指定命名空间和房间(包含发送者)

// 从 app 发送
this.app.of("myNamespace").to("room").emit("event", "message");
// 从 ctx 发送
this.ctx.nsp.emit("bigger-announcement", "the tournament will start soon");

发送给当前节点连接的所有客户端(多节点时为多进程场景)

this.app.local.emit("hi", "my lovely babies");

房间(Room)机制在实际项目中非常常用。仓库测试夹具 packages/socketio/test/fixtures/base-app-room/src/socket/api.ts 演示了完整的房间用法:客户端通过 joinRoom 事件调用 this.ctx.join(roomId) 加入房间,服务端分别用 this.ctx.app.to(roomId).emit(...) 与 this.ctx.to(roomId).emit(...) 实现包含发送者与不包含发送者的房间广播;对应测试 packages/socketio/test/index.test.ts 中断言发送到 room1 的消息只被 room1 的两个客户端收到,发送到 room2 的消息只被 room2 的客户端收到。

Application(io 对象)

传统 Socket.io 服务器的创建代码如下:

const io = require("socket.io")(3000);

io.on("connection", socket => {
  // ...
});

在 @midwayjs/socketio 框架中,Application 实例就是 io 实例,类型与能力完全一致。通过 @App 装饰器注入的 app 实例就是一个 io 对象。

我们可以通过这个对象做一些全局操作。

获取所有 socket 实例

// 返回所有 socket 实例
const sockets = await app.fetchSockets();

// 返回 room1 房间内的所有 socket 实例
const sockets = await app.in("room1").fetchSockets();

// 返回指定 socketId 对应的实例
const sockets = await app.in(theSocketId).fetchSockets();

在多个框架下获取 Socket.io 的 app

在多个框架并存时,主框架一般是 Web 框架。我们可以通过指定 key 来获取 Socket.io 的 app:

import { Application as SocketApplication } from '@midwayjs/socketio';
import { Controller, App } from '@midwayjs/core';

@Controller()
export class UserController {

  @App('socketIO')
  socketApp: SocketApplication;
}

这样,我们就可以通过 @midwayjs/socketio 的 app 对象(等价于 io)调用现有的 socket 连接。

例如,通过一个 HTTP 请求广播到特定命名空间下的所有客户端:

import { Application as SocketApplication } from '@midwayjs/socketio';
import { Provide, Controller, App, Get } from '@midwayjs/core';

@Controller()
export class UserController {

  @App('socketIO')
  socketApp: SocketApplication;

  @Get()
  async invoke() {
    // 广播到 / 命名空间下的所有连接
    this.socketApp.of('/').emit('hi', 'everyone');
  }
}

说明:@App('socketIO') 中的 'socketIO' 正是组件配置类声明的命名空间(见 packages/socketio/src/configuration.ts),同时与框架的 getFrameworkName() 返回值一致(见 packages/socketio/src/framework.ts)。在测试夹具中也可以看到使用 @MainApp() 注入同一 app 实例的等价写法。

更多 io API 可参考 Socket.io 的 Server instance 官方文档。

Socket 部署

Socket 服务端口

@midwayjs/socketio 的配置示例如下:

// src/config/config.default
export default {
  // ...
  socketIO: {
    port: 7001
  },
}

当 @midwayjs/socketio 与 @midwayjs/Web、@midwayjs/koa、@midwayjs/express 等 Web 框架同时启用时,可以复用 HTTP 端口:

// src/config/config.default
export default {
  // ...
  koa: {
    port: 7001
  },
  socketIO: {
    // 这里不配置端口
  },
}

此时框架会走 app.attach(httpServer) 路径,将 Socket.io 挂载到 Web 框架的 HTTP Server 上(见 packages/socketio/src/framework.ts 的 run() 方法)。

另外从源码看,若将 port 配置为 0,框架会通过 getFreePort()(见 packages/socketio/src/utils.ts)自动分配一个空闲端口,测试夹具 base-app 正是利用了这一特性。

Nginx 配置

一般来说,我们的 Node.js 服务前面会有 Nginx 之类的反向代理服务。这里以 Nginx 配置为例:

http {
  server {
    listen 80;
    server_name example.com;

    location / {
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $host;

      proxy_pass http://localhost:7001;

      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "upgrade";
    }
  }
}

要点在于 proxy_http_version 1.1 与 Upgrade/Connection 头,这是 WebSocket 协议升级得以通过 Nginx 透传的关键。

配置

可用配置项

@midwayjs/socketio 的配置通过 socketIO 键注入(框架在 configure() 中调用 this.configService.getConfiguration('socketIO') 读取,见 packages/socketio/src/framework.ts)。其类型 IMidwaySocketIOOptions 继承自 SocketIO.ServerOptions(见 packages/socketio/src/interface.ts),因此除了下表列出的项外,Socket.io 官方的全部 ServerOptions 均可用。

属性 类型 描述
port number 可选。如果传入了端口,socket.io 会为该端口创建一个 HTTP 服务并挂载 socket 服务;如果想与其他 Midway Web 框架一起使用,请不要传该参数
path string 可选,服务器路径
adapter object 分布式处理的适配器,例如可配置 redis-adapter
connectTimeout number 客户端超时时间,单位 ms,默认值 45000

更多启动选项可参考 Socket.io 的 Server 构造选项官方文档。

Adapter(分布式适配器)

Adapter 是 Socket.io 在分布式部署时用于多机、多进程通信的适配层。目前 Socket.io 官方提供了几种适配器:

    1. cluster-adapter:用于单机多进程之间的适配;
    1. redis-adapter:用于多机多进程之间的适配。

在分布式场景下,我们一般使用 redis-adapter 来实现功能。

配置 Redis Adapter

@midwayjs/socketio 提供了 adapter 入口配置,只需要初始化适配器实例并传入即可。

:::tip Socket.io 更新了原适配器的包名。当前包名为 @socket.io/redis-adapter(原名 socket.io-redis)。迁移相关信息见官方文档。 :::

安装方式:

$ npm i @socket.io/redis-adapter --save

配置示例:

// src/config/config.default
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';

// 按官方文档创建 redis 实例
const pubClient = new Redis(/* redis 配置 */);
const subClient = pubClient.duplicate();

export default {
  // ...
  socketIO: {
    adapter: createAdapter(pubClient, subClient)
  },
}

说明:源码层面,框架在 run() 中检测到 configurationOptions.adapter 时会执行 this.app.adapter(adapter) 完成注册(见 packages/socketio/src/framework.ts)。

通过使用 @socket.io/redis-adapter 适配器运行 Socket.io,可以在不同进程或服务器上运行多个 Socket.io 实例,这些实例之间可以互相广播和发送事件。

此外,Adapter 上还有一些特殊 API,可参考其官方文档。

Sticky Session(粘性会话)

由于 Node.js 启动时经常使用多进程(cluster)模式,如果同一个会话(sid)多次访问无法命中同一个进程,socket.io 就会报错。

有两种解决方案。

方案一:只启用 WebSocket 协议

最简单的方式是只启用 WebSocket 协议(禁用长轮询),从而规避上述问题。

需要同时配置服务器端和客户端:

// 服务器端
export default {
  //...
  socketIO: {
    //...
    transports: ['websocket'],
  },
}

// 客户端
const socket = io("http://127.0.0.1:7001", {
  transports: ['websocket']
});

方案二:调整进程模型

这是相对复杂的方式,但在 pm2 部署场景下,这是唯一能同时支持粘性会话与轮询的方案。

第一步,禁用配置中启用的端口,例如:

// src/config/config.default
export default {
  koa: {
    // port: 7001,
  },
  socketIO: {
    //...
  },
};

如果开发需要,可以在 config.local 中加上端口,或者直接在 package.json 的 scripts 中加端口:

"scripts": {
  "dev": "cross-env NODE_ENV=local midway-bin dev --ts --port=7001",
},

第二步,将 bootstrap.js 文件调整为以下代码:

const { Bootstrap, ClusterManager, setupStickyMaster } = require('@midwayjs/bootstrap');
const http = require('http');

// 创建进程管理器来处理子进程
const clusterManager = new ClusterManager({
  exec: __filename,
  count: 4,
  sticky: true, // 启用粘性会话支持
});

if (clusterManager.isPrimary()) {
  // 主进程启动一个 http server 用于监听
  const httpServer = http.createServer();
  setupStickyMaster(httpServer);

  // 启动子进程
  clusterManager.start().then(() => {
    // 监听端口
    httpServer.listen(7001);
    console.log('main process is ok');
  });

  clusterManager.onStop(async () => {
    // 停止时关闭 http server
    await httpServer.close();
  });
} else {
  // 子进程逻辑
  Bootstrap
    .run()
    .then(() => {
      console.log('child is ready');
    });
}

当 pm2 启动时,无需指定 -i 参数来启动 worker,直接 pm2 --name=xxx ./bootstrap.js 使其只启动一个进程即可。

常用 API

获取连接数量

const count = app.engine.clientsCount; // 获取所有连接数
const count = app.of('/').sockets.size; // 获取单个命名空间下的连接数

修改 sid 生成方式

const uuid = require("uuid");

app.engine.generateId = (req) => {
  return uuid.v4(); // 必须在所有 Socket.IO 服务器间保持唯一
}

常见问题

服务器/客户端无法连接且无响应

排查以下三点是否前后一致。

1. 端口必须一致。 服务器配置的端口:

export default {
  koa: {
    port: 7001, // 这里的端口
  }
}

// 或

export default {
  socketIO: {
    port: 7001, // 这里的端口
  }
}

必须与客户端连接的端口一致:

// socket.io 客户端
const socket = io('************:7001', {
  //...
});

// midway 的 socket.io 测试客户端
const client = await createSocketIOClient({
  port: 7001
});

2. 服务器与客户端的 path 必须一致。 path 指启动参数中的路径部分:

// config.default
export default {
  socketIO: {
    path: '/testPath' // 这是服务器路径
  }
}

必须与客户端的 path 一致:

// socket.io 客户端
const socket = io('************:7001', {
  path: '/testPath' // 这里是客户端路径
});

// Midway 的 socket.io 测试客户端
const client = await createSocketIOClient({
  path: '/testPath'
});

3. 服务器与客户端的命名空间必须一致。

// 服务器端
@WSController('/test') // 这里是服务器的命名空间
export class HelloController {
}

// socket.io 客户端
const io = require("socket.io-client")
io('*****:3000/test', {}); // 这里是客户端的命名空间

// midway 的 socket.io 测试客户端
const client = await createSocketIOClient({
  namespace: '/test',
});

配置 CORS

如果出现跨域错误,需要在启动时配置 cors 信息:

// config.default
export default {
  socketIO: {
    cors: {
      origin: "http://localhost:8080",
      methods: ["GET", "POST"]
    }
  }
}

具体参数可参考 Socket.io 的 Handling CORS 官方文档。

小结

本文围绕 @midwayjs/socketio 组件,完整覆盖了从安装接入、目录组织、控制器与事件装饰器、五级中间件体系、本地测试(含 ack 回执)、房间与命名空间广播、io 对象全局操作,到端口复用、Nginx 透传、Redis Adapter 分布式扩展、Sticky Session 多进程部署以及常见故障排查的全链路实践。需要进一步深入时,可以继续阅读仓库源码:packages/socketio/src/framework.ts(框架核心与事件分发)、packages/core/src/decorator/ws/webSocketEvent.ts(装饰器与事件类型建模)、packages/mock/src/client/socketio.ts(测试客户端封装),以及 packages/socketio/test/index.test.ts(覆盖默认命名空间、ack、多命名空间、正则命名空间、房间广播、中间件叠加、过滤器等场景的完整测试)。

登录后查看全文
midway