翻棋能玩之后,我又想加一个多人黄金矿工。当时我问了一个问题:还是点对点传输,不通就走服务器中转,房主负责裁判,是不是只更新 App 和网页就够了,不需要改 Rust?
后来,我们把服务端的职责收在通用房间和通信上,新增玩法尽量只改客户端。再往后,客户端里的翻棋、矿工和电影厅也被拆了出去,改成从插件市场下载。
上一篇文章主要记录翻棋的联机、Docker 部署和自动构建。这篇接着写后面的架构变化。具体编码、三端界面、测试和部分方案细化大量借助了 Codex。文中的实现以 0.9.2 为准。
新增玩法,为什么要修改服务端?
最初的需求很具体:两个人进一个房间,下一盘翻棋。房间是否开始、能不能加入、什么时候结束,都容易按这个游戏来安排。
换成黄金矿工,人数变成两到三人,还要计分、买道具、保存关卡。再换成电影厅,成员可以在播放中途加入,也没有“所有人准备后开局”这回事。如果把这些规则都塞进服务端,每加一种房间,就要跟着增加一套判断。
这里还有个很实际的原因:Rust 服务端跑在朋友的服务器上,属于白嫖,机器性能也忒差了。既然用着别人的资源,我就想尽量少占点 CPU、内存和带宽,也少给朋友添维护上的麻烦。客户端能做的计算、状态管理和存档,尽量由客户端承担;能直连的数据,也尽量不绕服务器。
所以我们让 Rust 只负责房间管理和协助通信。房间里做什么,由客户端功能模块决定。翻棋让房主裁判走棋,电影厅由房主控制播放,都是把业务处理放在客户端。以后做文件共享,也可以继续用这套房间发现和消息转发。
这里的“通用”有个具体范围。服务端仍然处理身份、连接、房主、路由授权、消息大小和资源回收,只把玩法规则留给客户端。当前九个基础功能分成几组就能说清:
| 能力 | 功能号 |
|---|---|
| 创建、查询、退出、关闭房间 | 100、101、104、105 |
| 转交入房申请、同步房主声明的授权 | 102、103 |
| 定向转发、向指定成员广播 | 200、201 |
| UDP 地址发现 | 210 |
入房流程按“申请—裁决—返回结果”组织:加入者调用 JOIN,服务端把申请交给房主,房主检查后回复,服务端再把结果送回加入者。密码、人数和当前业务是否允许加入,由房主端处理。
实现时还补上了一层通用路由授权。房主通过 grant 告诉服务端是否放行,服务端据此限制后续通信;裁决正文中的 allowed 等业务内容仍然原样转交。这样既能保留房主的裁决权,也能阻止尚未获准的连接随意向房间发消息。
这一点在重连时尤其容易混淆。能连上服务器、曾经申请过房间,都不等于现在仍是获准成员。房主恢复后要核对授权快照,再恢复自己的业务名单。此前出现过成员已经离开、房主恢复后却还保留旧座位的问题,就需要从这里处理。
基础协议只解释自己需要的字段
确定协议时,我们删掉了预先设计的业务字段。以后要传游戏动作、文件块还是媒体信息,都由对应客户端自己扩展,不必先替它们写进基础协议。
现在一帧由固定头、控制区和正文组成。固定头是 17 字节,记录标识、长度、版本、包类别和加密标记;控制区采用 key:value 加 LF 换行,例如:
op:200
roomId:R
to:C1
type:my.custom.action
这里的 R、C1 是便于阅读的占位符。服务端需要读 op、roomId 和 to 才能完成路由,type 则由客户端解释。非保留的扩展控制字段连同正文按原始字节透传,服务端不把它们重新整理成另一份业务 JSON。
普通消息正文可以用 JSON,文件块等大数据直接用原始字节。这样传一块二进制数据,就不用再把它转成 Base64 塞进 JSON。TCP、WebSocket 和 UDP 共用同一套帧格式,但各自仍按传输方式读取:TCP 读满一帧,WebSocket 一条二进制消息对应一帧,UDP 一个数据报承载一帧。
UDP 的探测、分片、重组和必要的重传由客户端适配器处理。服务端提供地址发现和信令转发,直连失败后的业务中转继续走 TCP 或 WebSocket。共用封装可以减少重复协议,不能让 UDP 自动获得可靠传输。
控制区改成逐行字段、正文减少解析,出发点是减少不必要的编解码。这部分没有做单独的性能基准,收益还不能量化。链路加解密和必要的缓冲处理仍然存在,谈不上全程零拷贝;服务端不解释业务正文,也不代表它无法接触正文。端到端加密房间目前还没实现。
完整的字节布局、握手和逐个功能号的流程放在接入手册。
断开连接以后,用户不必马上消失
手机网络波动、App 重启、网页刷新,都可能让连接断掉。所以我们把连接和用户在线状态拆开:释放已经断开的连接,暂时保留用户及房间关系,连续 60 秒没有有效心跳才清理。
恢复也有边界。客户端要凭有效身份恢复连接,再核对房间授权和本地状态。翻棋断线期间计时继续,以房主为准,不能重连一次就重新获得整段回合时间。
服务端不做业务持久化,是另一项明确的取舍。用户、房间和授权保存在内存里,服务端重启后失效,需要重建房间。矿工进度、房主的片源和播放设置留在客户端本地。
这样可以省掉服务端的业务存档和数据库,但恢复责任就落到了客户端:本地存档必须及时保存,也必须确认它还属于当前有效房间。一个过期存档不能把已经清理掉的房间重新“恢复”出来。
多人语音带来了指定广播
电影厅加入语音后,只考虑“客人发给房主,房主再发给其他人”就不太合适了。多人同时说话时,房主要承担别人的转发;如果每个目标都单独通过服务器发送,说话者又得重复上传相同内容。
所以我们给服务端增加了房间广播,并允许指定接收成员。广播的正文仍然由客户端定义,Rust 不需要新增语音格式或混音逻辑。后来又把直连拓扑调整成成员之间的 UDP 单播,让每个说话者直接处理自己的接收目标。
假设房间里有 H、A、B、C、D、E,A 能直连 B、C,其余成员需要中转。A 向 B、C 分别发送 UDP,再把相同的音频正文和 H、D、E 的目标名单上传一次,由服务端分发。H 只接收 A 的声音,不再替 A 转给其他人。
这个接口解决的是重复上传和额外转发路径。服务端依然要向每个接收者发送数据,出口带宽和各条连接的加密工作还在。直连也有自己的成本:逐目标单播会增加发送者上行压力,路径质量差时仍要退回中转。
后续又补上了丢包、抖动、发送积压和过期音频丢弃等处理。尤其声音过期以后,再可靠地送到也未必有用,所以发送前和播放前都要限制积压。广播本身并不能消除 TCP 的队头阻塞。
指定成员广播需要修改 Rust,但它提供的是之后其他插件也能复用的通信能力,没有把语音编解码搬进服务端。设计过程见广播与成员互连方案。
客户端也只留下底座
Rust 的职责收窄以后,客户端仍然包含翻棋、矿工和电影厅的全部内容。于是客户端也做了同样的拆分:赛博房间只保留通信、房间管理和 API,具体功能拆成插件,需要玩的时候再去市场下载。
目前三个功能分别放在 plugins/chess、plugins/miner 和 plugins/watch。宿主提供大厅、连接、安装、权限网关和平台桥接,插件拥有自己的规则、界面、业务同步和存档。Rust 不加载插件代码。
为了让大厅显示尚未安装插件的房间,服务端补了公开房间描述 metadata。创建时保存,查询时原样返回,并限制大小。里面的插件标识和发布信息由宿主解读,Rust 仍然把它当作不透明字符串。
客户端公共 SDK 提供通信接口,以及可选的房主房间助手。插件可以复用准备、成员和恢复等流程,也可以自己组织业务状态。电影厅没必要套用翻棋的“准备—开局—结束”。
插件通过清单声明实际支持的平台,可以只实现其中几端。本机和房主都检查平台与发布是否匹配,不支持的端不能进入。
首版不在房间里换插件,切换功能必须退出重建。房间绑定到 {id, version, release, protocol},加入者下载这个房间使用的精确发布,已有房间继续使用原发布。
这也意味着市场不能只保留“最新版”。有人还在旧房间里,新成员就可能需要对应旧包。发布流程因此保留历史产物,更新先下载并校验,准备成功以后再切换安装指针,失败则保留原安装。
下载包有签名和散列校验,脚本在隔离的 iframe 内通过受限接口调用宿主,拿不到宿主恢复凭据。原生组件则按审核过的可信代码处理。Android 组件在宿主进程执行,Windows 组件使用独立进程,两者都不能描述成任意原生代码的低权限沙箱。
首版市场仍由维护者审核和签名,没有开放自助上传。更新增加权限时再次征求同意的流程也暂未做。这些范围和最终接口都记在插件开发文档。
电影厅把同步边界暴露了出来
翻棋按步处理动作,电影播放却一直在推进。电影厅由房主选片和控制进度,成员可以随时加入。各端直接向 Emby 取视频,房间通信只同步播放状态、弹幕和语音;音量、清晰度、音轨、字幕由各端自己选择。
实际使用中,我复现过一个问题:PC 做房主,Web 中途加入,房主多点几次进度条后就无法继续播放。还有换集后旧请求影响新影片、跳转时误报片尾等情况。
其中一个具体错误出在时间换算上。播放器显示的位置不一定就是整部影片的位置:从指定起点截取的相对 HLS 清单需要加起点偏移,完整清单则不能再加一次。此前的测试没有覆盖完整清单,真实片源暴露了双重偏移问题。
连续跳转还要求区分当前请求与旧请求。第一次跳转尚未完成,房主又发起第二次,旧响应就不能再覆盖新目标。换片、断网恢复和原生播放器事件也要核对各自的上下文。成员重连后先等房主的新状态,不能凭本地旧进度自行续播。
这部分没有让 Rust 开始理解电影时间轴。服务端继续提供通用通信和授权,播放意图、播放器实际位置与加载状态由电影厅插件处理。
外部片源也有自己的故障。真实 Emby 曾有一轮晚加入、跳转和断网恢复完整通过,后续又返回 HTTP 500 / Error starting ffmpeg。片源属于第三方,缺少管理员日志,不能据此断定所有失败都与客户端无关。当前处理是明确报错、有限恢复,达到上限后交给用户手动重试。具体修复和验证边界见播放同步审查。
评论
欢迎交流。评论通过 GitHub 账号登录,保存在本博客的 Discussions 中。