WebSocket
WebSocket 允许服务器与浏览器之间保持一个持久的双向连接。与常规 HTTP 中始终由浏览器发起请求不同,WebSocket 允许服务器在任意时刻向客户端推送消息。
常见的使用场景:实时通知、实时聊天、自动更新的仪表盘、多人游戏。
Finch 的 WebSocket 支持围绕三个类构建:
SocketManager—— 管理所有活跃的 WebSocket 连接,并将消息分发给正确的路由处理程序。SocketEvent—— 为某个特定路径定义onConnect、onMessage、onDisconnect和onError回调。SocketClient—— 表示一个已连接的客户端;它会被传入每一个回调,你可以在它上面调用.send()。
真正把传入的 HTTP 请求升级为 WebSocket 连接的,是一个普通的 Finch Controller——并不存在一个单独需要继承的 SocketController 基类。
设置
1. 定义 SocketManager
在 app.dart 中创建 SocketManager。它接收 app 实例、一个用于生命周期事件的根 SocketEvent,以及一个具名路由处理程序的映射表:
final socketManager = SocketManager(
app,
event: SocketEvent(
onConnect: (socket) {
// 当客户端连接时调用
// 通知所有其他客户端有新连接加入
app.socketManager?.sendToAll(
'A user connected. Total: ${app.socketManager?.countClients}',
path: 'output',
);
// 向新连接的客户端发送确认消息
socket.send(
{'message': 'Successfully connected to socket!'},
path: 'connected',
);
},
onMessage: (socket, data) {
// 当消息未匹配到任何具名路由时调用
},
onDisconnect: (socket) {
// 当客户端断开连接时调用
var count = app.socketManager?.countClients ?? 0;
app.socketManager?.sendToAll(
'A user disconnected. Total: ${count - 1}',
path: 'output',
);
},
onError: (socket, data) {
// 当传入的消息无法被解析为 JSON,
// 或处理过程中发生其他错误时调用
},
),
routes: _getSocketRoutes(),
);
只有根 event 才会收到 onConnect/onDisconnect/onError 调用——详见下方"定义具名路由"小节中的说明。
2. 定义具名路由
Socket 路由是一个 Map<String, SocketEvent>。每个键都是一个"路径"(一个逻辑上的频道名称)。当客户端向该路径发送消息时,对应的 onMessage 回调就会被触发:
Map<String, SocketEvent> _getSocketRoutes() {
return {
// 客户端发送到路径 'test' —— 服务器回复请求头
'test': SocketEvent(
onMessage: (socket, data) {
socket.send([socket.rq.headers], path: 'test');
},
),
// 客户端发送到路径 'time' —— 服务器回复当前时间
'time': SocketEvent(
onMessage: (socket, data) {
socket.send(DateTime.now().toString(), path: 'output');
},
),
};
}
具名路由的
SocketEvent中只会使用onMessage——设置在路由条目上的onConnect/onDisconnect/onError永远不会被调用;这些回调只会从传给SocketManager的根event上触发。如果消息的path没有匹配到任何具名路由,则会转而落到根event.onMessage上处理。
3. 在控制器中升级请求
控制器的职责是把 HTTP 请求交给 socketManager,以完成升级——它只是一个普通的 Controller,没有任何特殊之处:
class WebSocketController extends Controller {
Future<String> socket() async {
// 将请求转交给 SocketManager 以完成 WebSocket 升级
await socketManager.requestHandle(rq);
return rq.renderSocket(); // 返回 'Socket is requested!'
}
}
4. 注册 WebSocket 路由
WebSocket 路由必须接受 Methods.ALL,因为升级握手使用的是带有特殊请求头的 GET 请求:
FinchRoute(
key: 'root.ws',
path: '/ws',
methods: Methods.ALL,
index: webSocketController.socket,
),
从服务器发送消息
每个回调都会收到一个 SocketClient(在上面的示例中称为 socket)。它本身以及 SocketManager 都提供了用于发送消息的方法:
// 发送给触发该事件的特定客户端
socket.send(data, path: 'channelName');
// 发送给所有已连接的客户端
app.socketManager?.sendToAll(data, path: 'channelName');
// 通过连接 ID 发送给某一个特定客户端
// (注意:该方法的真实名称有一个拼写错误:是 "Clinet",而不是 "Client")
app.socketManager?.sendToClinet(clientId, data, path: 'channelName');
// 发送给与某个用户 ID 关联的所有客户端
// (要求该客户端连接时,调用 requestHandle(rq, userId: ...) 传入了 userId)
app.socketManager?.sendToUser(userId, data, path: 'channelName');
没有内置的"发送给除某一个客户端外的所有人"的方法——如果你需要这个功能,可以自行过滤 app.socketManager?.getAllClientsKeys(),然后对剩下的每一个 ID 调用 sendToClinet。
发送调用中的 path 决定了客户端一侧由哪个处理程序接收该消息。在 JavaScript 一侧,请监听相同路径名称上的消息。
客户端 JavaScript 示例
const ws = new WebSocket('ws://localhost:8080/ws');
ws.onopen = () => console.log('Connected');
// 监听 'connected' 路径上的消息
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.path === 'connected') {
console.log('Server says:', msg.data.message);
}
if (msg.path === 'output') {
console.log('Output:', msg.data);
}
};
// 向服务器上的 'time' 路由发送一条消息
ws.send(JSON.stringify({ path: 'time', data: {} }));
已连接的客户端数量
int count = app.socketManager?.countClients ?? 0;
int users = app.socketManager?.countUsers ?? 0; // 唯一用户数,如果连接时传入了 userId