无自有域名实现微信云托管 Socket.IO WebSocket

95 阅读5分钟

核心挑战:​​ 微信云托管提供的测试 WSS 地址无法用于正式发布,且小程序端 weapp.socketio库同样仅限测试。在缺乏备案域名的情况下,如何实现正式环境的 WebSocket 连接?

解决方案:​​ 利用微信云托管提供的 wx.cloud.connectContainerAPI 建立与后端容器的 WebSocket 连接。由于该 API 提供的是标准的 WebSocket 连接,我们需要在客户端手动实现 Engine.IO 和 Socket.IO 协议。

一、协议基础

在实现代码前,需要理解两个关键协议:

  1. Engine.IO (v4):​​ 底层传输协议,负责建立连接、心跳、消息分帧等。其数据包类型使用单个数字标识:

    • 0: OPEN (连接初始化)
    • 1: CLOSE (关闭连接)
    • 2: PING (心跳请求)
    • 3: PONG (心跳响应)
    • 4: MESSAGE (上层消息)
    • 5: UPGRADE (协议升级 - WebSocket 场景通常不需要)
    • 6: NOOP (空操作 - 用于轮询传输)
  2. Socket.IO (v5):​​ 构建在 Engine.IO 之上的高层协议,提供命名空间、房间、事件广播等功能。其数据包类型在 Engine.IO 的 MESSAGE(4) 包内,使用第二个数字标识:

    • 0: CONNECT (连接到命名空间)
    • 1: DISCONNECT (从命名空间断开)
    • 2: EVENT (事件)
    • 3: ACK (事件确认)
    • 4: CONNECT_ERROR (命名空间连接错误)
    • 5: BINARY_EVENT (二进制事件)
    • 6: BINARY_ACK (二进制确认)

关键理解:​​ 一个 Socket.IO 通信包通常由两个数字开头。第一个数字是 Engine.IO 类型,第二个数字是 Socket.IO 类型。例如,40表示 Engine.IO 的 MESSAGE包,其内容是 Socket.IO 的 CONNECT包。

二、代码实现

1. 连接云托管容器 & 基本事件处理

// 连接云托管容器
const { socketTask } = await wx.cloud.connectContainer({
  service: this.service, // 微信云托管服务列表中的服务名称
  path: '/socket.io/?transport=websocket&namespace=%2F&EIO=4', // Socket.IO 服务的标准连接路径
});
this.container = socketTask;
console.log('云托管容器 WebSocket 连接成功');

// 监听 WebSocket 打开事件
this.container.onOpen(() => {
  this._isConnected = true;
  console.log('WebSocket 连接已建立,发送 Socket.IO CONNECT 包 (40)');

  // 发送 Socket.IO CONNECT 包: Engine.IO 类型 4 (MESSAGE) + Socket.IO 类型 0 (CONNECT)
  const connectPacket = '40'; // 连接根命名空间 '/'
  this._sendSocketIOPacketRaw(connectPacket);
});

// 监听 WebSocket 消息事件
this.container.onMessage((res) => {
  if (this._debug) {
    console.log('收到原始 WebSocket 消息:', res);
  }
  this._handleSocketIOMessage(res); // 处理 Engine.IO/Socket.IO 协议
});

// 监听 WebSocket 关闭事件
this.container.onClose(() => {
  console.log('WebSocket 连接已关闭');
  this._isConnected = false;
  this._emitInternal('ws_close', {}); // 触发内部关闭事件

  // 非主动断开,尝试自动重连
  if (!this._manuallyClosed) {
    setTimeout(() => {
      if (this._manuallyClosed) return;
      console.log('尝试重新连接...');
      this.connect(this.service);
    }, this._reconnectDelay);
  }
});

// 监听 WebSocket 错误事件
this.container.onError((err) => {
  console.error('WebSocket 连接发生错误:', err);
  this._emitInternal('ws_error', err); // 触发内部错误事件
});

2. 加入/离开房间 & 发送事件

// 加入房间
join(roomId) {
  if (!roomId) return;
  const id = Number(roomId);
  this._joinedRooms.add(id);

  // 发送 'room:join' 事件到服务器
  // 格式: Engine.IO 类型 4 (MESSAGE) + Socket.IO 类型 2 (EVENT) + 事件数据
  this.emitEvent('room:join', { roomId: id });
}

// 离开房间
leave(roomId) {
  if (!roomId) return;
  const id = Number(roomId);
  this._joinedRooms.delete(id);

  // 发送 'room:leave' 事件到服务器
  this.emitEvent('room:leave', { roomId: id });
}

// 发送 Socket.IO 事件 (核心方法)
emitEvent(eventName, data) {
  // 构造 Socket.IO EVENT 包结构
  const packet = {
    type: 4,      // Engine.IO MESSAGE
    subType: 2,   // Socket.IO EVENT
    action: eventName,
    data: data
  };
  this._sendSocketIOPacket(packet);
}

// 构造并发送符合 Socket.IO 协议的包
_sendSocketIOPacket(packet) {
  if (!this.container) return;

  let packetString = '';
  if (packet.type !== undefined) {
    packetString = `${packet.type}`; // Engine.IO 类型

    if (packet.subType !== undefined) {
      packetString += `${packet.subType}`; // Socket.IO 类型
    }

    // 构造事件数据数组: [eventName, eventData]
    const sendData = [];
    if (packet.action !== undefined) {
      sendData.push(packet.action);
    }
    if (packet.data !== undefined) {
      sendData.push(packet.data);
    }

    // 如果有数据,序列化并附加到包字符串
    if (sendData.length > 0) {
      packetString += JSON.stringify(sendData);
    }
  }

  // 发送最终的协议字符串
  this._sendSocketIOPacketRaw(packetString);
}

// 发送原始协议字符串 (处理连接状态和队列)
_sendSocketIOPacketRaw(packetString) {
  if (!this.container) return;

  if (this._isConnected) {
    this.container.send({
      data: packetString,
      success: () => {
        if (this._debug) {
          console.log('Socket.IO 包发送成功:', packetString.substring(0, 50) + (packetString.length > 50 ? '...' : ''));
        }
      },
      fail: (err) => {
        console.error('Socket.IO 包发送失败:', err);
        this._messageQueue.push(packetString); // 加入重发队列
      }
    });
  } else {
    this._messageQueue.push(packetString); // 连接未建立,加入队列等待
  }
}

3. 解析服务器消息 (核心逻辑)

// 处理从 WebSocket 收到的原始消息
_handleSocketIOMessage(res) {
  try {
    let data = res.data || res;

    if (typeof data === 'string') {
      // 解析协议字符串: [EngineIOType][SocketIOType?][JSONData]
      const packetType = parseInt(data.charAt(0)); // Engine.IO 类型
      let subType = -1; // Socket.IO 类型 (默认为 -1 表示可能没有)
      let jsonDataStart = 1; // JSON 数据起始位置

      // 检查第二个字符是否是数字 (即是否有 Socket.IO 类型)
      if (data.length > 1 && !isNaN(parseInt(data.charAt(1))) {
        subType = parseInt(data.charAt(1));
        jsonDataStart = 2;
      }

      // 提取 JSON 数据部分 (如果有)
      const packetData = (jsonDataStart < data.length) ? data.substring(jsonDataStart) : null;
      let parsedData = null;

      // 尝试解析 JSON 数据
      if (packetData && (packetData.startsWith('[') || packetData.startsWith('{'))) {
        try {
          parsedData = JSON.parse(packetData);
        } catch (e) {
          console.warn('解析 JSON 数据失败:', packetData, e);
        }
      }

      if (this._debug) {
        console.log('解析协议包:', `EIO:${packetType}`, `SIO:${subType}`, 'Data:', parsedData || packetData);
      }

      // 交给包处理器
      this._handleSocketIOPacket({
        type: packetType,
        subType: subType,
        data: parsedData || packetData // 优先使用解析后的对象,否则用原始字符串
      });

    } else if (typeof data === 'object') {
      // 处理非协议格式的普通对象消息 (根据业务需要)
      this._handleNormalMessage(data);
    }
  } catch (error) {
    console.error('消息处理过程中发生错误:', error, '原始消息:', res);
  }
}

// 处理解析后的协议包
_handleSocketIOPacket(packet) {
  const { type, subType, data } = packet;

  switch (type) {
    case 0: // Engine.IO OPEN (握手响应)
      console.log('收到 Engine.IO OPEN 包:', data);
      if (data && data.sid) {
        console.log('Engine.IO 连接成功,SID:', data.sid);
        this._sid = data.sid; // 保存会话 ID
      }
      break;

    case 1: // Engine.IO CLOSE
      console.log('收到 Engine.IO CLOSE 包');
      break;

    case 2: // Engine.IO PING
      console.log('收到 Engine.IO PING,发送 PONG');
      this._sendPong(); // 必须响应 PONG
      break;

    case 3: // Engine.IO PONG
      console.log('收到 Engine.IO PONG');
      break;

    case 4: // Engine.IO MESSAGE (包含 Socket.IO 包)
      // 根据 Socket.IO 子类型 (subType) 处理
      switch (subType) {
        case 0: // Socket.IO CONNECT
          console.log('Socket.IO 连接成功!');
          this._reconnectAttempts = 0;
          this._flushQueue(); // 发送积压的消息
          this._restoreRooms(); // 重新加入之前的房间
          this._emitInternal('ws_open', {}); // 通知应用层连接就绪
          break;

        case 1: // Socket.IO DISCONNECT
          console.log('收到 Socket.IO DISCONNECT');
          this._emitInternal('ws_close', {});
          break;

        case 2: // Socket.IO EVENT
          try {
            this._handleSocketIOEvent(data); // 处理服务器发来的事件
          } catch (error) {
            console.error('处理 Socket.IO 事件失败:', error, data);
          }
          break;

        case 3: // Socket.IO ACK (事件确认)
          console.log('收到 ACK 包');
          // 处理确认逻辑 (如有需要)
          break;

        case 4: // Socket.IO CONNECT_ERROR
          console.error('Socket.IO 连接错误:', data);
          this._emitInternal('ws_error', data);
          break;

        default:
          console.log('收到未知的 Socket.IO 子类型包:', subType, data);
      }
      break;

    default:
      console.log('收到未知的 Engine.IO 类型包:', type, data);
  }
}

4. 心跳处理

// 响应 Engine.IO PING
_sendPong() {
  // Engine.IO PONG 包: 类型 `3`
  this._sendSocketIOPacketRaw('3');
}

三、总结与注意事项

  1. 适用场景:​​ 此方案是解决微信小程序正式环境无法直接使用测试 WSS 地址和 weapp.socketio库的替代方案,特别适用于没有备案域名的情况。
  2. 核心原理:​​ 利用 wx.cloud.connectContainer建立标准 WebSocket 连接,客户端手动实现​ Engine.IO (v4) 和 Socket.IO (v5) 协议。
  3. 协议关键:​​ 深刻理解 Engine.IO 包类型和 Socket.IO 包类型(作为 Engine.IO MESSAGE 的子类型)的格式与含义是成功实现的基础。
  4. 连接流程:​​ WebSocket onOpen-> 发送 40(Socket.IO CONNECT) -> 收到 40(Socket.IO CONNECT 成功) -> 连接就绪。
  5. 心跳:​​ 客户端被动响应 PING (2) 为 PONG (3)。
  6. 调试:​​ 强烈建议开启 _debug模式,详细打印发送和接收的协议包,是定位问题的关键。
  7. 重连:​​ 实现了基本的自动重连逻辑和消息队列重发。
  8. 小程序限制:​​ 注意微信小程序对 WebSocket 并发数和后台运行的限制。

体验:​​ 基于此方案实现的微信小程序 WebSocket 应用已上线,欢迎体验

22228.jpg