WebSocket

WebSocket 允许服务器与浏览器之间保持一个持久的双向连接。与常规 HTTP 中始终由浏览器发起请求不同,WebSocket 允许服务器在任意时刻向客户端推送消息。

常见的使用场景:实时通知、实时聊天、自动更新的仪表盘、多人游戏。

Finch 的 WebSocket 支持围绕三个类构建:

  • SocketManager —— 管理所有活跃的 WebSocket 连接,并将消息分发给正确的路由处理程序。
  • SocketEvent —— 为某个特定路径定义 onConnectonMessageonDisconnectonError 回调。
  • 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