Midway 实时通信实战:使用 @midwayjs/socketio 组件构建 Socket.io 实时双向通信服务
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(传输模式):
- HTTP 长轮询:HTTP
GET请求用于长时间运行(长连接),POST请求用于短时运行(短连接)。 - WebSocket 协议:直接基于 WebSocket Connection 实现,提供服务器与客户端之间的双向、低延迟通信通道。
默认情况下,Socket.io 会先用 HTTP 长轮询建立连接,并发送一段类似如下结构的数据:
{
"sid": "FSDjX-WRwSA4zTZMALqx", // 连接的会话 id
"upgrades": ["WebSocket"], // 可升级的协议
"pingInterval": 2500, // 心跳间隔(ms)
"pingTimeout": 20000 // 心跳超时(ms)
}
当当前服务满足升级到 WebSocket 协议的条件时,连接会自动升级到 WebSocket 协议,升级过程如下:
-
- 第一次握手,传输
sid等结构;
- 第一次握手,传输
-
- 使用 HTTP 长轮询发送数据;
-
- 使用 HTTP 长轮询返回数据;
-
- 升级协议,改用 WebSocket 协议发送数据;
-
- 协议升级完成后,关闭之前的长轮询。
此后便进入正常的 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
-
- 这里的端口只是测试时 WebSocket 服务启动的端口;
-
- 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 官方提供了几种适配器:
-
cluster-adapter:用于单机多进程之间的适配;
-
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、多命名空间、正则命名空间、房间广播、中间件叠加、过滤器等场景的完整测试)。