百游棋牌源代码开发搭建教程(七):WebSocket房间同步与断线恢复

文章摘要: 在已有HTTP房间指令之上接入WebSocket订阅,完成连接认证、房间快照、状态序号、自动重连和退出清理,演示断网后如何恢复画面,以及怎样区分连接恢复与操作重试。

文章目录


前言

前六篇已经能够通过HTTP完成一局棋。但是两名玩家分别打开客户端时,一方落子后,另一方还需要主动查询房间。实时同步要解决的是让观察者及时看到服务端已经确认的状态。本篇在同一套工程中增加订阅,不改变第五篇的指令入口和第六篇的判定逻辑。

这里采用"HTTP提交指令,WebSocket推送完整快照"的教学实现。对15×15公开棋盘,完整快照容易调试,也能直接修复漏掉的通知。它不是面向大规模房间的最终广播方案,后续扩大用户规模时需要依据测量数据设计分区和消息分发。

一、明确两条通信链路的责任

一次落子的顺序是:客户端生成requestId,发送HTTP指令;服务端完成认证、锁定房间、判断规则和数据库提交;然后尽力通知订阅者。只有提交成功的状态才能被其他玩家看见。

text 复制代码
玩家点击格子
  → POST /api/rooms/:roomId/commands
  → 认证与事务
  → 写入状态、事件、幂等结果
  → COMMIT
  → HTTP返回 + WebSocket快照

两个返回渠道到达的先后顺序没有保证。客户端不能要求HTTP一定先于WebSocket,也不能把一次快照当成新增一次得分的命令。界面根据快照覆盖显示,累计战绩则由第八篇的服务端流水查询提供。

如果HTTP发生超时,指令可能已经提交。此时使用原来的requestId重试,沿用第五篇的方法;WebSocket重新连上只会恢复订阅,不会替你再次落子。将这两个动作分开,是避免重连后重复操作的关键。

二、检查服务端与联调账号

在示例工程目录启动完整服务,而不是第一篇的最小服务:

powershell 复制代码
pnpm start

另开PowerShell确认数据库就绪:

powershell 复制代码
Invoke-RestMethod http://127.0.0.1:3000/health/ready

结果应为status等于ready。准备一个有效会话和该账号已经加入的roomId。WebSocket连接成功只说明网络握手通过,没有完成身份认证,也不表示账号具有房间访问权限。

第五篇使用的两个账号都可继续使用,但如果会话已经超过一小时,应先重新登录。本工程没有自动续期接口,不能把旧令牌保存一个星期后仍当成有效会话使用。

三、约定连接上的消息格式

客户端打开连接后先发送AUTH。令牌放在消息体内,避免出现在URL查询参数中。以下token和roomId均为格式示意,需要替换为实际登录结果和实际房间编号。

json 复制代码
{"type":"AUTH","token":"替换为登录返回的64位十六进制令牌"}

服务端确认身份后返回:

json 复制代码
{"type":"AUTH_OK"}

收到AUTH_OK之后才能发送WATCH,不能在同一个瞬间连发两条消息。本实现有并行消息保护,前一条消息尚未处理完时继续发送会关闭连接。

json 复制代码
{"type":"WATCH","roomId":"替换为实际房间UUID"}

快照消息的data与HTTP查询房间返回的对象一致,包含id、status、rule_version、seq、state和members等字段。服务端还可能返回ERROR;客户端应展示具体错误并决定是否重新登录,不能将所有错误统称为"网络不好"。

四、阅读完整的服务端订阅实现

下面代码来自src/app.mjs中注册WebSocket的部分,位于buildApp函数内部。它使用该函数已经声明的pool、peers、uuid以及引入的identify、roomView和fail;阅读时要结合附件完整文件,不要单独创建一个缺少这些变量的新文件。

javascript 复制代码
  await app.register(websocket, { options: { maxPayload: 8192, perMessageDeflate: false } });
  app.get('/ws', { websocket: true }, socket => {
    const peer = { socket, token: null, roomId: null, busy: false };
    peers.add(peer);
    const authDeadline = setTimeout(() => { if (!peer.token) socket.close(1008, 'AUTH_REQUIRED'); }, 5000);
    socket.on('close', () => { clearTimeout(authDeadline); peers.delete(peer); });
    socket.on('error', () => { clearTimeout(authDeadline); peers.delete(peer); });
    socket.on('message', async raw => {
      if (peer.busy) return socket.close(1008, 'TOO_MANY_MESSAGES');
      peer.busy = true;
      try {
        if (!pool) fail('DATABASE_NOT_CONFIGURED', 503);
        const msg = JSON.parse(raw.toString());
        if (msg.type === 'AUTH') {
          await identify(pool, msg.token);
          peer.token = msg.token;
          clearTimeout(authDeadline);
          send(peer, { type: 'AUTH_OK' });
        } else if (msg.type === 'WATCH') {
          const user = await identify(pool, peer.token);
          if (typeof msg.roomId !== 'string' || !new RegExp(uuid.pattern).test(msg.roomId)) fail('BAD_ROOM_ID');
          peer.roomId = msg.roomId;
          send(peer, { type: 'SNAPSHOT', data: await roomView(pool, peer.roomId, user.id) });
        } else fail('UNKNOWN_MESSAGE');
      } catch (error) {
        send(peer, { type: 'ERROR', code: error.statusCode && error.statusCode < 500 ? error.code : 'BAD_MESSAGE' });
        if (error.statusCode === 401) socket.close(1008, 'UNAUTHORIZED');
      } finally { peer.busy = false; }
    });
  });
  function send(peer, message) {
    if (peer.socket.readyState !== 1) return;
    if (peer.socket.bufferedAmount > 65536) { peer.socket.close(1008, 'SLOW_CLIENT'); return; }
    peer.socket.send(JSON.stringify(message));
  }
  let refreshing = false;
  async function refreshPeers(roomId) {
    // Periodic full snapshots repair missed or overlapping best-effort notifications.
    if (refreshing) return;
    refreshing = true;
    try {
      await Promise.all([...peers].filter(p => p.token && p.roomId && (!roomId || p.roomId === roomId)).map(async peer => {
        try {
          const user = await identify(pool, peer.token);
          send(peer, { type: 'SNAPSHOT', data: await roomView(pool, peer.roomId, user.id) });
        } catch (error) {
          if ([401,403].includes(error.statusCode)) peer.socket.close(1008, 'ACCESS_REVOKED');
          else send(peer, { type: 'ERROR', code: 'TEMPORARILY_UNAVAILABLE' });
        }
      }));
    } finally { refreshing = false; }
  }
  const refreshTimer = setInterval(() => void refreshPeers(), 5000);
  refreshTimer.unref();

这里有五个需要逐项检查的细节。首先,消息监听器同步注册,异步认证放在监听器内部,避免在等待数据库期间遗漏早到消息。其次,连接建立后五秒仍未认证就关闭,防止未认证连接长期占用资源。再次,WATCH查询会校验房间成员身份,知道roomId并不自动获得查看权限。

第四,发送之前检查连接状态和待发送缓冲区。对于持续收不动数据的客户端,直接关闭连接比无限堆积消息更容易控制内存。第五,所有认证过且订阅房间的连接每五秒重新获取一次快照。这个周期可以修复一次通知失败,也会带来额外数据库读取,不能忽略其成本。

当前代码在一次刷新尚未结束时跳过重叠刷新,因此无法承诺每次状态变化都有独立推送。客户端最终以新快照收敛;如果业务需要逐个播放动画,还需另行设计带序号的事件补拉,不能把本实现描述成已经具备完整增量同步。

五、在浏览器控制台完成一次订阅

打开http://127.0.0.1:3000/demo,按F12进入控制台。先通过接口登录取得token,再执行下面的代码。控制台变量仅用于本地调试,截图和教程发布时不要暴露真实令牌。

javascript 复制代码
const login = await fetch('/api/auth/login', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({username: 'player_a', password: '替换为自己的密码'})
}).then(async response => {
  if (!response.ok) throw new Error(await response.text());
  return response.json();
});
const roomId = prompt('输入当前账号已加入的房间UUID');
const socket = new WebSocket('ws://127.0.0.1:3000/ws');
socket.onopen = () => socket.send(JSON.stringify({type:'AUTH', token:login.token}));
socket.onmessage = event => {
  const message = JSON.parse(event.data);
  if (message.type === 'AUTH_OK') socket.send(JSON.stringify({type:'WATCH', roomId}));
  if (message.type === 'SNAPSHOT') console.log(message.data.seq, message.data.status);
  if (message.type === 'ERROR') console.warn(message.code);
};
socket.onclose = event => console.log('closed', event.code, event.reason);

正确结果是先认证,再收到快照;即使没有落子,也会周期性收到相同seq的快照。第二个窗口落子后,观察第一个窗口seq增加。没有增加时先用HTTP查询该房间,判断是动作没有提交,还是订阅没有更新。

六、在Cocos中加入可复用连接类

把附件cocos/RoomSocket.ts复制到Cocos工程的assets/scripts/net/RoomSocket.ts。这是普通TypeScript类,不需要作为组件挂载到节点。它由房间组件创建,在房间销毁时主动停止。

typescript 复制代码
export class RoomSocket {
  private socket: WebSocket | null = null;
  private retry: ReturnType<typeof setTimeout> | null = null;
  private watchdog: ReturnType<typeof setInterval> | null = null;
  private generation = 0;
  private stopped = true;
  private attempt = 0;
  private seq = -1;
  constructor(private onView: (view: any) => void, private onState: (text: string) => void) {}
  start(url: string, token: string, roomId: string) {
    this.stop(); this.stopped = false; this.seq = -1; this.attempt = 0;
    const connect = () => {
      if (this.stopped) return;
      const generation = ++this.generation;
      const ws = new WebSocket(url); this.socket = ws;
      let lastMessage = Date.now();
      const current = () => !this.stopped && generation === this.generation;
      this.onState('CONNECTING');
      ws.onopen = () => { if (current()) ws.send(JSON.stringify({type:'AUTH',token})); };
      ws.onmessage = event => {
        if (!current()) return;
        lastMessage = Date.now();
        let msg: any;
        try { msg = JSON.parse(String(event.data)); }
        catch { this.onState('BAD_SERVER_MESSAGE'); this.stop(); return; }
        if (msg.type === 'AUTH_OK') ws.send(JSON.stringify({type:'WATCH',roomId}));
        if (msg.type === 'ERROR') this.onState(msg.code);
        if (msg.type === 'SNAPSHOT' && msg.data?.id === roomId && Number.isInteger(msg.data.seq)) {
          this.attempt = 0;
          if (msg.data.seq >= this.seq) {
            this.seq = msg.data.seq; this.onView(msg.data); this.onState('SYNCED');
          }
        }
      };
      ws.onerror = () => { if (current()) this.onState('NETWORK_ERROR'); };
      if (this.watchdog) clearInterval(this.watchdog);
      this.watchdog = setInterval(() => {
        if (current() && Date.now() - lastMessage > 25000) ws.close();
      }, 5000);
      ws.onclose = event => {
        if (!current()) return;
        if (this.watchdog) { clearInterval(this.watchdog); this.watchdog = null; }
        if (event.code === 1008) { this.onState('REAUTH_OR_ACCESS_CHECK_REQUIRED'); this.stop(); return; }
        this.onState('RECONNECTING');
        const delay = Math.min(1000 * 2 ** this.attempt++, 10000) + Math.random() * 300;
        this.retry = setTimeout(connect, delay);
      };
    };
    connect();
  }
  stop() {
    this.stopped = true; this.generation++;
    if (this.retry) clearTimeout(this.retry);
    if (this.watchdog) clearInterval(this.watchdog);
    this.retry = null; this.watchdog = null;
    if (this.socket) { this.socket.onclose = null; this.socket.close(); }
    this.socket = null;
  }
}

generation用于区分新旧连接。玩家切换房间后,旧连接的延迟回调可能仍然到达;如果不检查所属代次,旧房间就可能覆盖新房间画面。seq用于识别同一房间的新旧快照,低于已显示序号的快照会被忽略,相同序号可重复覆盖但不能重复播放结算奖励动画。

重连间隔从约一秒开始递增,上限约十秒,并加少量随机延迟。收到有效快照后才重置重试次数,避免连接刚打开就断开时不断高频重试。1008代表策略或权限问题,本类停止自动重连,要求界面引导用户检查登录状态或访问权限。

七、把连接生命周期接到房间组件

下面是调用方式,假定当前组件已经从登录流程拿到token,从创建或加入流程拿到roomId。onView接收的是服务端视图,绘制函数应从board重新渲染,避免将相同快照重复追加到棋盘。

typescript 复制代码
private roomSocket: RoomSocket | null = null;

enterRoom(token: string, roomId: string) {
  this.roomSocket?.stop();
  this.roomSocket = new RoomSocket(
    view => {
      if (!this.isValid) return;
      this.renderRoom(view);
    },
    state => {
      if (!this.isValid) return;
      this.statusLabel.string = state;
    }
  );
  this.roomSocket.start('ws://127.0.0.1:3000/ws', token, roomId);
}

onDestroy() {
  this.roomSocket?.stop();
  this.roomSocket = null;
}

这段是房间组件的接入片段,renderRoom和statusLabel属于你的界面层,需要按第四篇的组件绑定方法实现;附件没有伪装成已经包含完整Cocos棋盘场景。暂时没有美术和场景时,可先用/demo完成协议验证,再实现客户端表现。

八、执行断网与重启演练

先让两名玩家进入同一房间并准备。断开其中一个客户端的网络,在另一个客户端完成轮到自己的合法落子,再恢复网络。使用RoomSocket类的客户端应经历RECONNECTING、CONNECTING、SYNCED,最终显示服务端快照中的棋子和seq。

浏览器开发者工具的离线模式对现存WebSocket的处理可能与真实断网不同;如果连接未断,关闭网络适配器或通过停止服务模拟连接丢失。停止服务会影响全部客户端,不应在其他人正在验收的共享环境随意执行。

随后重启Node服务,保留PostgreSQL运行。房间状态应从数据库恢复;连接重新认证并订阅。若房间变空,检查是否误换数据库地址、误清空数据库或连接了第一篇的最小服务,而不是立即修改客户端重连次数。

九、处理"画面恢复了,操作结果不确定"

断网前点击落子,HTTP没有返回,重连后看到那一格有棋子,并不足以普遍证明该requestId已经成功。严谨的处理是保留原请求内容和requestId,再次提交同一请求,由服务端幂等结果给出确认。

本例的本地demo有待确认指令重试入口,但Cocos连接类只负责连接,不保存业务请求。正式客户端需要一个独立待确认队列,并在登出、切账号和切房间时处理归属。不要在新账号登录后替旧账号自动重发操作。

十、常见问题定位

握手返回403时,检查页面Origin与ALLOWED_ORIGINS是否一致,包括协议、域名和端口。连接成功后五秒关闭,检查是否设置了onopen并发送AUTH。收到NOT_A_MEMBER,先调用JOIN接口并确认成功,再订阅房间。

序号不断更新但画面不变,检查渲染代码是否仍读取旧对象、是否把board索引行列写反。退出房间后日志仍持续输出,检查组件销毁是否调用stop,以及是否重复创建多个连接实例。HTTPS网页必须连接WSS,不能连接明文WS造成混合内容拦截。

十一、记录容量边界与下一步改造

每个订阅连接每五秒读取会话和房间,这种写法适合教学和小规模验证,连接数增长会直接提高查询量。正式扩展应先测量连接数、查询耗时、数据库连接池等待和快照大小,再考虑按房间合并读取、缓存公开视图和跨实例广播。

隐藏手牌游戏必须生成每名玩家各自可见的视图。不能把G1公开棋盘快照直接改成包含全部手牌的对象广播,再指望客户端隐藏节点保护数据。权限和信息裁剪应在服务端发送前完成。

十二、按故障时间线检查同步行为

1. 连接建立不等于房间恢复

状态CONNECTING表示正在建立网络连接,AUTH_OK只确认会话有效,收到目标房间的SNAPSHOT之后才进入SYNCED。界面应依据这几个阶段显示不同提示,不能在onopen时立即解除所有操作限制。否则玩家尚未取得最新seq就提交旧动作,服务端会正确拒绝,而用户只觉得刚连上又出错。

本示例连接类在收到快照时重置重试次数,正是把可用业务状态作为恢复标准。实际房间界面还应根据status和turn决定哪些按钮可用。连接正常但尚未轮到本人,落子按钮仍然不应可用;网络状态与游戏操作资格是两组独立条件。

2. 快照不能作为重复播放的事件

服务端每五秒发送一次完整状态,因此同一获胜快照可能重复出现。渲染棋盘可以覆盖执行,播放获胜音效、弹窗和积分动画却不能每次都重复。界面可以用房间编号和已处理的结束序号识别首次进入结束状态,重新打开历史房间时则按照产品需要展示静态结果。

这不代表客户端负责最终结算。动画标记丢失最多影响表现,数据库结算仍然按第八篇的事务和唯一约束处理。把"弹窗已经播放过"当成服务端不再结算的依据,会让不同设备之间出现无法协调的状态。

3. 先观察服务端,再观察渲染层

另一名玩家看不到新棋子时,依次检查动作HTTP是否成功、数据库seq是否增加、WebSocket是否收到同序号快照、渲染函数是否执行。若HTTP返回STALE_STATE,问题尚未进入广播阶段;若快照已到但画面未变,增加服务端推送频率也没有帮助。

建议联调时只输出roomId、seq、status和消息类型,不打印全部令牌与状态。公开棋盘日志也可能过大,复杂牌类还涉及隐藏信息。调试日志越有针对性,越容易在断网恢复前后比较,而不必从大量重复数组中寻找一个序号变化。

4. 切换账号时先停止旧连接

用户退出A账号并登录B账号时,旧WebSocket不会因为本地变量改变自动换身份。必须停止旧连接、清除旧房间状态,再用B的会话建立新连接。不能只把页面头像改成B,后台仍用A令牌订阅,否则画面身份与实际权限就会错位。

同样,切房间时清理旧连接和待确认动作,避免旧房间回调晚到覆盖新房间。generation只保护当前连接实例内的新旧回调,如果业务层创建两个不同RoomSocket实例却没有停止旧实例,仍可能收到两套数据,因此组件的所有权也要清楚。

5. 连接长期没有消息的处理

本类每五秒检查一次最后消息时间,超过二十五秒会主动关闭并尝试恢复。这个时间不是网络质量承诺,而是本例在五秒周期快照基础上的观察阈值。移动应用退到后台时,系统可能暂停定时器,所以恢复前台后仍要检查业务状态,不能假定后台心跳一直准确运行。

服务端数据库不可用时可能发送临时错误,说明连接存在但业务读取失败。界面应保留最后一次确认的画面并提示暂不可用,不把旧画面当成最新可操作状态。恢复后重新得到快照,再允许基于新seq提交动作。

6. 多实例部署前检查通知覆盖

当前peers集合属于单个Node进程。房间在实例A被修改,实例B不会收到A的内存通知,但本例周期读取数据库可以最终发现变化。它提供的是简单的状态收敛方式,延迟与数据库负载会随实例和连接数变化,不等于已经实现高效跨实例广播。

后续增加消息系统时仍应保留快照恢复入口,因为消息可能重发、漏收或在订阅建立前发生。用房间序号识别状态新旧、用数据库保存权威结果的思路可以延续,具体广播实现则需要独立的负载和故障验证。

联调结束后在控制台执行socket.close(),并关闭专门打开的测试窗口。多个残留连接会继续产生快照查询,干扰后续对连接数和数据库负载的观察。每轮实验记录实际打开的连接数量,才有条件比较不同同步实现的成本。

十三、先做一个能独立运行的WebSocket订阅探针

前面的浏览器控制台适合第一次确认协议,但它需要手工粘贴代码,不方便重复验收。下面将建立连接、认证、订阅和接收快照整理成一个Node脚本。文件保存为scripts/ws-probe.mjs,与第六篇的rules-online.mjs放在同一目录。脚本使用Node 24内置WebSocket,不需要另装客户端依赖。

它只订阅当前账号已经加入的房间,不创建账号、不提交落子,也不在连接失败后擅自重试业务动作。先看这个简单探针是否能取得正确快照,再去检查Cocos组件生命周期,可以减少同时排查多个问题的负担。

完整代码如下:

javascript 复制代码
import { pathToFileURL } from 'node:url';

export function messageQueue(socket, timeoutMs = 8000) {
  const queue = [];
  const waiters = [];
  let closed = false;
  function receive(raw) {
    let message;
    try { message = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); }
    catch { message = { type: 'ERROR', code: 'BAD_JSON' }; }
    const waiter = waiters.shift();
    if (waiter) { clearTimeout(waiter.timer); waiter.resolve(message); }
    else if (queue.length < 100) queue.push(message);
    else stop(new Error('PROBE_QUEUE_OVERFLOW'));
  }
  function stop(error = new Error('SOCKET_CLOSED')) {
    closed = true;
    for (const waiter of waiters.splice(0)) {
      clearTimeout(waiter.timer);
      waiter.reject(error);
    }
  }
  // Browser/Node built-in WebSocket and ws used by Fastify tests have different events.
  if (typeof socket.addEventListener === 'function') {
    socket.addEventListener('message', event => receive(event.data));
    socket.addEventListener('close', () => stop());
    socket.addEventListener('error', () => stop(new Error('SOCKET_ERROR')));
  } else {
    socket.on('message', receive);
    socket.on('close', () => stop());
    socket.on('error', () => stop(new Error('SOCKET_ERROR')));
  }
  return {
    next() {
      if (queue.length) return Promise.resolve(queue.shift());
      if (closed) return Promise.reject(new Error('SOCKET_CLOSED'));
      return new Promise((resolve, reject) => {
        const waiter = { resolve, reject, timer: null };
        waiter.timer = setTimeout(() => {
          const index = waiters.indexOf(waiter);
          if (index >= 0) waiters.splice(index, 1);
          reject(new Error('MESSAGE_TIMEOUT'));
        }, timeoutMs);
        waiters.push(waiter);
      });
    },
    async snapshot(roomId, minimumSeq = 0) {
      // Bound the number of unrelated messages; the next() timeout bounds each wait.
      for (let attempt = 0; attempt < 30; attempt++) {
        const msg = await this.next();
        if (msg.type === 'ERROR') throw new Error(msg.code);
        if (msg.type === 'SNAPSHOT' && msg.data?.id === roomId && msg.data.seq >= minimumSeq) {
          return msg.data;
        }
      }
      throw new Error('EXPECTED_SNAPSHOT_NOT_FOUND');
    },
    stop
  };
}

export async function subscribe(socket, messages, token, roomId) {
  socket.send(JSON.stringify({ type: 'AUTH', token }));
  const auth = await messages.next();
  if (auth.type !== 'AUTH_OK') throw new Error(auth.code || 'AUTH_FAILED');
  socket.send(JSON.stringify({ type: 'WATCH', roomId }));
  return messages.snapshot(roomId);
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const base = new URL(process.env.BASE_URL || 'http://127.0.0.1:3000');
  if (!['http:', 'https:'].includes(base.protocol)) throw new Error('Use HTTP(S) BASE_URL');
  const token = process.env.API_TOKEN;
  const roomId = process.env.ROOM_ID;
  if (!token || !roomId) throw new Error('Set API_TOKEN and ROOM_ID');
  base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
  base.pathname = '/ws'; base.search = ''; base.hash = '';
  const socket = new WebSocket(base);
  const messages = messageQueue(socket);
  try {
    await new Promise((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error('OPEN_TIMEOUT')), 8000);
      socket.addEventListener('open', () => { clearTimeout(timer); resolve(); }, { once: true });
      socket.addEventListener('error', () => { clearTimeout(timer); reject(new Error('OPEN_FAILED')); }, { once: true });
    });
    const first = await subscribe(socket, messages, token, roomId);
    console.log(JSON.stringify({ phase: 'subscribed', roomId, seq: first.seq, status: first.status }));
    const next = await messages.snapshot(roomId, first.seq);
    console.log(JSON.stringify({ phase: 'next_snapshot', roomId, seq: next.seq, status: next.status }));
  } finally {
    messages.stop();
    socket.close();
  }
}

1. 为什么先建立消息队列,再发送认证

WebSocket消息可能很快到达。如果先发AUTH,过一会儿再注册监听器,AUTH_OK就可能已经被处理掉了,调用者会一直等一个不会再来的响应。messageQueue在发送动作前完成监听注册,无论调用方有没有开始等待,消息都先进入队列。

next优先返回已经排队的消息;没有消息才创建带超时的等待者。等待结束后清除定时器,超时后从waiters移除相应对象。否则已经超时的等待者可能继续吞掉下一条快照,让后续订阅表现成偶发丢消息。

队列最多保留一百条消息,只是探针的资源上限。达到上限意味着使用方式或接收速度异常,探针停止等待并抛出错误,而不是无限缓存。本文件的stop结束队列等待,不负责替所有调用者关闭socket;命令行入口在finally统一关闭连接,测试代码也会主动terminate测试连接。

2. 不将第一条消息一律当成快照

subscribe先等待AUTH_OK,再发WATCH;snapshot只接收目标roomId和达到minimumSeq的SNAPSHOT。ERROR直接转换为明确异常,避免访问权限错误被伪装成"没有消息"。收到别的合法消息可以继续等待,但等待次数有上限,不会永久吞掉协议异常。

命令行模式输出两次状态:第一次订阅完成,第二次收到不低于第一次序号的快照。第二次序号可以相同,因为服务端周期性推送完整状态。这个探针证明订阅和后续消息链路可用,不承诺第二次输出一定代表一次新落子。

要观察变化,可以在另一个窗口完成一次合法动作;测试代码会把minimumSeq设置为动作之后的序号,明确要求看到更新。不能用收到任意快照作为"这次落子已经同步"的证据。

3. 实际运行方法

在已登录的PowerShell终端里执行,使用前文的真实token和roomId。BASE_URL填写HTTP入口,脚本会将http转换为ws、https转换为wss,并固定使用/ws路径。本系列代理没有子路径前缀,因此脚本不处理/app/ws之类自定义路由。

powershell 复制代码
$env:BASE_URL = 'http://127.0.0.1:3000'
$env:API_TOKEN = $loginA.token
$env:ROOM_ID = $roomId
try {
    node scripts/ws-probe.mjs
    if ($LASTEXITCODE -ne 0) { throw 'WebSocket probe failed' }
} finally {
    Remove-Item Env:API_TOKEN -ErrorAction SilentlyContinue
    Remove-Item Env:ROOM_ID -ErrorAction SilentlyContinue
    Remove-Item Env:BASE_URL -ErrorAction SilentlyContinue
}

输出只含房间编号、序号和状态,不输出令牌与完整棋盘。探针正常结束会关闭连接,不会在后台留下一个持续读取数据库的观察者。若第一次成功而第二次超时,先检查服务端五秒刷新是否实际运行,再检查中间代理是否保持长连接。

十四、把"收到快照"和"允许操作"写成可测试逻辑

很多客户端问题并不发生在socket上,而是发生在渲染层:旧房间覆盖新房间、断线时仍可点击、相同终局重复弹窗。下面将这一小部分行为从场景组件抽出来,保存为web/room-view-state.mjs。它是普通JavaScript模块,可用于浏览器联调,不依赖DOM或美术资源。

javascript 复制代码
export function createViewState(roomId) {
  return { roomId, seq: -1, view: null, connected: false, announced: new Set() };
}

export function setConnected(model, value) {
  model.connected = Boolean(value);
}

export function acceptSnapshot(model, view) {
  if (!view || view.id !== model.roomId || !Number.isInteger(view.seq) || view.seq < 0) {
    return { accepted: false, reason: 'WRONG_ROOM_OR_SEQUENCE' };
  }
  if (view.seq < model.seq) return { accepted: false, reason: 'STALE' };
  const same = view.seq === model.seq;
  model.seq = view.seq;
  model.view = structuredClone(view);
  model.connected = true;
  const key = `${view.id}:${view.seq}`;
  const announce = view.status === 'FINISHED' && !model.announced.has(key);
  if (announce) model.announced.add(key);
  return { accepted: true, same, announce };
}

export function mayPlay(model, mySeat) {
  const view = model.view;
  return Boolean(model.connected && [0, 1].includes(mySeat) && view &&
    view.status === 'PLAYING' && view.state?.winner === null &&
    view.state.draw === false && view.state.turn === mySeat);
}

这个模块按单个房间维护状态。createViewState在进入房间时创建,不要让所有房间共享一份全局seq。两个房间都有序号12是正常情况,不能只凭数字相同判断数据属于同一局。

acceptSnapshot先检查目标房间,再比较序号。相同序号允许覆盖画面,并将连接标记恢复为可用;较低序号直接拒绝。收到新数据时复制对象,避免渲染层在临时高亮某个棋子时把原始网络数据也改掉。

announced只记录当前客户端实例已经展示过的终局序号。它不持久化、不控制数据库结算,也不代表用户换设备后永不再看到结果。如果产品需要跨页面只展示一次,需要单独定义会话级表现策略;服务端积分是否写入只能通过结算表判断。

mayPlay要求连接有效、房间为PLAYING、尚无胜负或平局且轮到本人。它没有替代服务端校验,而是让界面避免发出明显无效操作。即使这一步返回true,在请求到达前状态仍可能变化,服务端依然可能返回STALE_STATE。

1. 接入已有连接类的方法

下面是浏览器模块的接线片段,假设已有roomSocket实例以及实际roomId、mySeat。它不是完整Cocos组件,不应把DOM变量直接粘进Creator。Cocos侧可以保留相同判断方式,再用节点和组件更新画面。

javascript 复制代码
import { createViewState, acceptSnapshot, mayPlay, setConnected } from './room-view-state.mjs';

const model = createViewState(roomId);
function onRoomView(view) {
  const change = acceptSnapshot(model, view);
  if (!change.accepted) return;
  renderBoard(model.view.state.board);
  setPlayEnabled(mayPlay(model, mySeat));
  if (change.announce) showResult(model.view.state);
}
function onConnectionState(state) {
  if (state !== 'SYNCED') setConnected(model, false);
  setPlayEnabled(mayPlay(model, mySeat));
  showConnectionState(state);
}

renderBoard、setPlayEnabled、showResult和showConnectionState都是界面层适配函数,需要由具体页面提供;上面的状态模块本身是完整文件。把适配位置明确标出来,可以避免将几行调用代码误认为已经包含可发布的场景。

连接类会在收到有效快照时调用onView,再通知SYNCED。这里的快照处理已将model.connected设为true;其他连接状态则把它设为false。临时数据库错误同样应禁用操作,而不是只在TCP断开时禁用,因为连接存在不等于业务状态可以读取。

2. 页面退后台后如何使用这套状态

退后台时可以先禁用交互,恢复前台后等待新快照或主动查询。不能因为本地保存了一个较大的seq,就认为它一定是最新。序号只能比较已收到的两份状态,无法证明服务器在客户端睡眠期间没有发生变化。

如果恢复后HTTP查询和WebSocket都返回快照,统一通过同一个acceptSnapshot入口处理,避免两套代码各自维护一个seq。先到哪条数据都可以,后到的低序号会被拒绝。若两个入口返回相同序号却不同棋盘,属于数据一致性异常,应保存诊断信息,而不是随机选择一份继续运行。

十五、准备可重复使用的隔离测试环境

后面三篇都会需要数据库、账号和房间。如果每个测试各写一遍初始化,修改认证流程时容易漏掉某个副本。新增support/lesson-fixture.mjs集中处理建表、Fastify实例、账号准备和资源关闭。它只供测试导入,线上src/main.mjs不会加载它。

javascript 复制代码
import { readFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import assert from 'node:assert/strict';
import { PGlite } from '@electric-sql/pglite';
import { buildApp } from '../src/app.mjs';

export async function fixture() {
  const db = new PGlite();
  let app;
  try {
    await db.exec(await readFile(new URL('../sql/001-initial.sql', import.meta.url), 'utf8'));
    let failure = null;
    const query = async (sql, args = []) => {
      if (failure && failure(sql, args)) {
        failure = null;
        throw new Error('INJECTED_WRITE_FAILURE');
      }
      const result = await db.query(sql, args);
      return { ...result, rowCount: result.rows.length || result.affectedRows || 0 };
    };
    const pool = { query, connect: async () => ({ query, release() {} }) };
    app = await buildApp({ databaseUrl: '', origins: [], logger: false }, pool);
    const api = async (url, method = 'GET', payload, token) => {
      const response = await app.inject({ url, method, payload,
        headers: token ? { authorization: 'Bearer ' + token } : {} });
      return { status: response.statusCode, value: response.json() };
    };
    const ok = async (...args) => {
      const result = await api(...args);
      assert.ok(result.status >= 200 && result.status < 300, JSON.stringify(result));
      return result.value;
    };
    async function user() {
      const credentials = {
        username: 'test_' + randomUUID().replaceAll('-', '').slice(0, 14),
        password: randomUUID() + '!Aa9'
      };
      await ok('/api/auth/register', 'POST', credentials);
      return ok('/api/auth/login', 'POST', credentials);
    }
    async function startRoom() {
      const players = [await user(), await user()];
      const created = await ok('/api/rooms', 'POST', { requestId: randomUUID() }, players[0].token);
      const path = '/api/rooms/' + created.roomId;
      const view = () => ok(path, 'GET', undefined, players[0].token);
      const send = (seat, input) => ok(path + '/commands', 'POST', input, players[seat].token);
      await send(1, { requestId: randomUUID(), action: 'JOIN' });
      for (const seat of [0, 1]) {
        await send(seat, { requestId: randomUUID(), action: 'READY', expectedSeq: (await view()).seq });
      }
      async function move(seat, row, col) {
        const input = { requestId: randomUUID(), action: 'PLAY', expectedSeq: (await view()).seq, row, col };
        return { input, result: await send(seat, input) };
      }
      return { players, roomId: created.roomId, path, view, send, move };
    }
    return { db, app, pool, query, api, ok, user, startRoom,
      failNext(predicate) { failure = predicate; },
      async close() { try { await app.close(); } finally { await db.close(); } }
    };
  } catch (error) {
    if (app) await app.close();
    await db.close();
    throw error;
  }
}

fixture每次创建一个独立PGlite数据库。不同测试不会共用房间,也不会读取本地.env中的DATABASE_URL。query适配器把查询结果补成业务代码需要的rowCount字段,再通过suppliedPool交给buildApp,仍然执行相同的注册、认证、房间和规则逻辑。

startRoom不是直接往rooms填一个PLAYING对象,而是通过接口创建两个账号,建房、加入、分别准备。这样可以同时验证前置流程。如果准备步骤因代码变更失效,测试会在那个步骤明确失败,不会用人工数据掩盖问题。

failNext是后两篇故障注入的入口。它只在本测试对象中拦截一条匹配SQL并抛错,没有修改生产服务,也没有给普通请求增加"制造错误"参数。匹配成功后立刻清空故障条件,所以可以在同一场景中先验证回滚,再用原请求重试成功。

该适配器只有一条连接,不适合模拟多请求同时争锁。不要在它上面用Promise.all并发调用事务,再把得到的结果称为真实PostgreSQL竞争测试。此处请求顺序执行,观察的是业务流程与错误处理;真实多连接测试仍属于部署环境验收。

十六、完整验证推送、断开、重订阅与越权

文件保存为test/sync-detailed.test.mjs。它包含纯客户端状态测试,以及直接通过Fastify注入WebSocket的集成测试。前者不需要网络,后者执行真实的认证和房间查询,但仍然不是手机无线网络实测。

javascript 复制代码
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from '../support/lesson-fixture.mjs';
import { messageQueue, subscribe } from '../scripts/ws-probe.mjs';
import { createViewState, acceptSnapshot, mayPlay, setConnected } from '../web/room-view-state.mjs';

test('view rejects old room and stale seq; identical terminal snapshot announces once', () => {
  const model = createViewState('room-a');
  const view = { id: 'room-a', seq: 5, status: 'PLAYING', state: { winner: null, draw: false, turn: 0 } };
  assert.equal(acceptSnapshot(model, view).accepted, true);
  assert.equal(mayPlay(model, 0), true);
  assert.equal(mayPlay(model, 1), false);
  assert.equal(acceptSnapshot(model, { ...view, seq: 4 }).accepted, false);
  assert.equal(acceptSnapshot(model, { ...view, id: 'room-b', seq: 99 }).accepted, false);
  assert.equal(model.seq, 5);
  setConnected(model, false);
  assert.equal(mayPlay(model, 0), false);
  const finished = { ...view, seq: 6, status: 'FINISHED' };
  assert.equal(acceptSnapshot(model, finished).announce, true);
  assert.equal(acceptSnapshot(model, finished).announce, false);
  assert.equal(mayPlay(model, 0), false);
});

test('socket authenticates, observes move and reconnects to current state', async () => {
  const f = await fixture();
  const sockets = [];
  try {
    const room = await f.startRoom();
    async function connect() {
      const socket = await f.app.injectWS('/ws');
      sockets.push(socket);
      const messages = messageQueue(socket);
      const view = await subscribe(socket, messages, room.players[1].token, room.roomId);
      return { socket, messages, view };
    }
    const first = await connect();
    assert.equal(first.view.seq, 3);
    await room.move(0, 7, 7);
    assert.equal((await first.messages.snapshot(room.roomId, 4)).state.board[112], 1);
    first.socket.terminate();
    await room.move(1, 6, 7);
    const restored = await connect();
    assert.equal(restored.view.seq, 5);
    assert.equal(restored.view.state.moves, 2);
    assert.equal(restored.view.state.board[97], 2);
  } finally {
    for (const socket of sockets) socket.terminate();
    await f.close();
  }
});

test('nonmember and revoked token cannot obtain authorized snapshot', async () => {
  const f = await fixture();
  const sockets = [];
  try {
    const room = await f.startRoom();
    const outsider = await f.user();
    const socket = await f.app.injectWS('/ws'); sockets.push(socket);
    const messages = messageQueue(socket);
    await assert.rejects(subscribe(socket, messages, outsider.token, room.roomId), /NOT_A_MEMBER/);
    socket.terminate();
    await f.ok('/api/auth/logout', 'POST', undefined, outsider.token);
    const revoked = await f.app.injectWS('/ws'); sockets.push(revoked);
    await assert.rejects(subscribe(revoked, messageQueue(revoked), outsider.token, room.roomId), /UNAUTHORIZED/);
  } finally {
    for (const socket of sockets) socket.terminate();
    await f.close();
  }
});

第一个用例先接收序号5,再送入序号4和另一个房间的序号99,确认数字大并不能绕过房间归属检查。它还验证断线后落子按钮禁用,以及同一个终局快照只产生一次announce。测试中只使用影响判断的最少字段,实际渲染仍使用完整房间视图。

第二个用例在序号3时订阅,黑方落子后要求收到至少序号4的快照,然后主动终止连接。离线期间白方落子,新连接重新认证订阅后直接拿到序号5和两颗棋子。恢复不依赖旧连接补发历史消息,只依赖当前快照,因此也能覆盖断开期间遗漏通知的情况。

第三个用例使用有效的外部账号。AUTH应该成功,但WATCH应因非成员身份失败;随后注销令牌,再建立新连接,AUTH也应失败。这样的分步验证能区分登录身份和房间权限,避免错误地认为所有403都是登录失效。

测试finally对每个socket执行terminate,再关闭应用和数据库。不要只在用例成功时清理,否则失败的订阅可能继续触发周期查询,影响后续用例和进程退出。命令行探针用标准close,测试用terminate只是为了确保测试资源及时收回,两者用途不同。

powershell 复制代码
node --test test/sync-detailed.test.mjs

十七、用时间线检查重连与请求重试是否被混在一起

设客户端最后看到seq为4,随后白方发出一条PLAY,HTTP响应尚未到达就断网。服务端可能已经提交为5,也可能没有接受请求。重新连接后拿到5并不能让通用客户端仅凭数字断言该请求成功,因为其他允许的业务动作也可能增加序号。

正确处理是保留原requestId、expectedSeq、动作类型和坐标。WebSocket负责恢复当前状态,HTTP幂等入口负责确认那条具体命令,两条路径各有明确证据。不能在收到SYNCED时遍历点击记录、为每条记录生成新requestId重新发送。

如果旧请求返回REQUEST_ID_CONFLICT,优先检查客户端是否重用了编号或重试时修改了expectedSeq;如果返回原成功结果,应移除待确认项;如果会话失效,应先重新确认账号归属。不同账号之间不能共享待确认队列,即使它们都在同一个设备上登录过。

本篇新增探针没有持久化待确认队列,RoomSocket也没有实现它。这不是隐藏的自动能力,客户端正式实现时应独立保存命令状态,明确清理时机。此处已经给出了同步链路的可执行验证方法,后续扩展队列时可以继续复用它检查不会重复落子。

十八、验收时分别记录"连接可用"和"业务追上"

建议一轮演练记录四个时点:开始断开、恢复网络、收到AUTH_OK、收到目标房间最新快照。AUTH_OK之前属于连接与认证耗时,之后到最新快照属于订阅和状态读取耗时。把两者合成一个"重连耗时",会掩盖数据库慢查询与网络握手慢的差别。

本工程的五秒周期刷新会修复一次被跳过的通知,所以在测试即时推送时应先确认没有落入周期修复路径。进程内用例允许在超时范围内看到快照,验证的是最终可恢复;它不是延迟基准测试,也没有给出毫秒级服务承诺。

真正扩大连接数前,需要测量每秒快照读取次数、序列化后的平均消息大小、数据库连接池等待和慢客户端数量。可以先对一条示例快照计算字节数,再结合真实连接数估算流量,但不要直接用理论值冒充压测成绩。

javascript 复制代码
const encoded = JSON.stringify({ type: 'SNAPSHOT', data: roomView });
const bytes = new TextEncoder().encode(encoded).byteLength;
console.log({ snapshotBytes: bytes, seq: roomView.seq });

这里的roomView是本地已取得的房间对象,不是同名服务函数。中文昵称和其他多字节字符会影响UTF-8字节数,因此不能简单用字符串length代替实际网络载荷大小。压缩、TLS和协议帧还有额外开销,这个数字只描述JSON正文。

十九、本篇完成后应留下哪些结果

完成本篇后,应能用订阅探针取得两次快照,用自动测试重现断开后的最新状态,并确认非成员和已撤销令牌不能正常订阅。客户端状态模块应拒绝旧房间和旧序号,禁止断线期间操作,避免重复显示同一终局动画。

Cocos连接类、浏览器状态模块和Node探针承担不同职责:连接类管理生命周期,状态模块处理画面资格,探针辅助协议诊断。它们没有替代已有HTTP指令,也没有增加新的结算入口。之后第八篇将在同一份房间、事件和积分数据上继续验证历史与结算完整性。

本篇新增测试已在进程内运行,真实移动网络、Cocos组件绑定和原生平台后台恢复仍需实际设备验证。技术实现与限制可参考Fastify WebSocket插件说明

相关推荐
binqian1 小时前
Docker Desktop(WSL2 后端)三层网络互通技术文档
网络·docker·容器
Lhappy嘻嘻2 小时前
网络(六)|应用层 HTTP&HTTPS:URL、请求响应、Cookie 与证书
网络·http·https
星恒讯工业路由器2 小时前
四张网协同演进:新一代通信网对工业通信设备意味着什么?
网络·物联网·智能路由器·工业路由器·工业物联网·5g-a·新一代通信网
小程序设计2 小时前
校园网络资产自动探测与安全状态评估系统设计与实现
网络·安全
新时代牛马3 小时前
PCI与PCIe 完整篇:硬件拓扑→ 报文协议→配置/BAR → Linux 驱动(一条主线讲透)
linux·运维·网络
筝筝ba3 小时前
如何跳过FFDC
xml·linux·运维·服务器·网络
AIFQuant3 小时前
Python股票实时价格告警系统:WebSocket订阅与REST快照实战
开发语言·python·websocket·a股行情
小肥君3 小时前
前端测试websocket
前端·websocket·状态模式
●VON3 小时前
Flutter 鸿蒙插件适配实战:用 network_status_bridge 0.0.5 查询并监听默认网络
网络·flutter·华为·harmonyos·鸿蒙