Build dossier · 20
Whiteboard:实时协作工具 MVP
记录一个自托管实时协作白板 MVP 的实现:Canvas、SQLite、WebSocket、WebRTC、coturn 和 Caddy 是怎么组装起来的。
最近做了一个给自己和小团队使用的实时协作白板。要求不复杂:能画图、发消息、上传图片和 PDF,再带一个简单的浏览器音视频通话;部署尽量省事,最好一台服务器就能跑。
目前项目还是 MVP,功能和跨浏览器体验都在继续调整。这篇文章不做架构方法论,也不假装所有问题都已经解决,只记录当前代码用了哪些组件、它们怎么连起来,以及后面可能要改什么。
目前要做的东西
第一版功能范围是:
- 创建房间,通过链接邀请其他人;
- 在同一块画布上画路径、图形、文字和便签;
- 同步光标、激光笔、绘制和拖动预览;
- 保存画布元素和聊天消息;
- 上传图片和 PDF;
- 最多 6 人进行浏览器音视频通话;
- 私有部署,站点入口有密码保护。
暂时没有账户系统、组织权限、多机部署和大型会议。先把小范围协作跑通,后面是否扩展看实际使用情况。
整体组件结构
当前版本是一套单机结构:
- 前端是 React + Canvas2D,构建后交给 Caddy 静态托管;
- Node.js 提供 HTTP API、WebSocket、房间权限和 RTC 信令;
- SQLite 保存房间、画布元素、聊天和操作记录;
- 图片和 PDF 放在本地附件目录;
- coturn 提供 STUN/TURN;
- systemd 管理 Node 和 coturn 进程。
Node 只监听 127.0.0.1:8787,不直接暴露到公网。Caddy 把 /api/* 和 /ws 转发给 Node,其余请求返回静态前端。Caddy 的同级 handle 是互斥匹配,正好适合这种 API、WebSocket 和 SPA fallback 的路由方式。Caddy handle 文档
画布实现:React + Canvas2D
画布没有接入完整的第三方白板 SDK,而是直接用 Canvas2D。React 负责工具栏、房间面板和通话界面,canvas 自己维护绘制、命中测试、选择框、缩放和平移。
画布里有两种数据:
- 最终元素,例如矩形、路径、文字、图片和 PDF,需要同步并保存;
- 临时交互,例如光标、激光点、正在绘制的路径和拖动预览,只需要实时广播。
这两种数据分开后,鼠标移动不用不停写数据库,断线重连时也只恢复最终元素。临时动画直接清掉,等新的事件即可。
Canvas2D 的麻烦主要在坐标和交互细节。屏幕坐标要经过 canvas 偏移、视口平移和缩放才能变成世界坐标。早期版本用归一化包围盒还原直线和箭头,反向拖动时起点会漂移;现在元素直接保留世界坐标中的真实起点和终点,预览、提交和远端重放使用同一套转换。激光笔也固定在世界坐标层绘制,画布容器则跟随可用视口高度,不再退化成页面顶部的一条窄区域。
数据存储:Node.js + SQLite
因为是单机 MVP,没有先上独立的 PostgreSQL 或 Redis。服务端直接使用 Node.js 内置的 node:sqlite,用 DatabaseSync 和 prepared statement 完成同步读写。Node.js SQLite 文档
白板写入里保留了三个字段:
revision:判断客户端是不是基于当前版本修改;seq:记录房间事件顺序;opId:防止网络重试把同一操作执行两次。
消息本身类似这样:
{
"type": "op.put",
"opId": "client-generated-id",
"baseRevision": 12,
"element": {
"id": "element-id",
"type": "rect",
"x": 80,
"y": 40
}
}
服务端先检查消息结构、房间权限、revision 和 opId,然后在事务里更新元素、操作记录和 seq。只有事务提交成功才广播,避免客户端看到数据库里其实不存在的结果。
图片和 PDF 没有塞进 JSON。数据库只记录 assetId、URL 和页码,文件放在附件目录,通过带权限检查的接口读取。这样导出的场景 JSON 比较小,但完整备份时必须同时复制 SQLite 和附件目录。
消息同步:WebSocket 分类广播
WebSocket 只负责传消息。真正需要长期保存的内容仍然走 SQLite。
| 消息类型 | 是否保存 | 例子 |
|---|---|---|
| 最终数据 | 是 | 元素更新、聊天、房间锁定 |
| 临时协作 | 否 | 光标、激光笔、绘制和拖动预览 |
| RTC 信令 | 否 | 加入通话、SDP、ICE candidate、离会 |
客户端重连后重新请求房间快照,然后继续接收新事件。断线期间的光标和预览不会补发。
标准 WebSocket API 不会自动提供 backpressure。如果消息生产速度超过处理速度,浏览器仍可能积累缓冲,因此服务端限制了 payload,客户端也需要控制高频事件的发送频率。MDN WebSocket 文档
聊天区另外维护“是否正在跟随最新消息”的状态。用户停留在底部时,新消息会自动滚动;如果正在翻看历史记录,界面只增加未读计数,不强行抢走当前位置。自己发出的消息会回到底部,输入框同时区分 Enter 发送、Shift+Enter 换行和输入法组合状态。
分享链接第一次打开时可以确认或修改昵称和颜色,选择会保存在当前浏览器;再次进入仍允许调整,而不是把邀请者生成的初始昵称永久锁死。音视频设备设置默认折叠,麦克风和摄像头偏好同样保存在本地。如果原设备被拔掉或浏览器返回的设备标识发生变化,客户端会退回系统默认设备,而不是用失效的 deviceId 直接中断入会。
音视频连接:WebRTC + coturn
音视频使用浏览器原生 WebRTC。Node 服务只通过 WebSocket 转发 SDP 和 ICE candidate,不接收实际音视频流。
浏览器产生 candidate 后,经 WebSocket 发给对端;对端设置好 remote description 后,再调用 addIceCandidate()。如果 candidate 先于 SDP 到达,客户端先放入队列,等 remote description 生效后按顺序写入。这是 MDN WebRTC 信令示例采用的基本流程。MDN WebRTC 信令指南 MDN addIceCandidate()
真正容易出错的是并发协商。两端可能同时因为加轨、换设备或恢复连接触发 offer,单靠检查一次 signalingState 仍会遇到竞态。当前实现采用 Perfect Negotiation 的 polite/impolite 分工,并把同一连接上的 SDP 与 candidate 处理放进串行任务队列;旧的异步任务还会携带生命周期标记,用户离会后才完成的 getUserMedia() 或协商结果不能重新激活已经关闭的连接。MDN Perfect Negotiation 指南
ICE 进入 failed 后会进行有限次数的 restartIce(),并保留尚未执行的重启请求,等信令重新回到 stable 再协商。媒体申请则按“指定设备、系统默认、仅音频或仅接收”的顺序降级,避免某一个失效设备让整场通话无法加入。MDN restartIce()
ICE 会尝试不同的连接候选,无法直连时使用 TURN relay。RFC 8445 coturn 同时配置了私网 peer 限制,避免 TURN 被当成访问服务器内网的代理。coturn 配置文档
当前通话是最多 6 人的 mesh。人数不多时实现简单,但每个浏览器都要维护多条连接,继续增加人数会明显占用上行带宽和编码资源。
Android Chrome、iOS 浏览器和 PC Chrome 已完成实际通话验证。最近一次 PC Chrome 生产会话中,Offer/Answer、ICE connected、最终 candidate pair 和双向音视频流量都成立,TURN 的 UDP、TCP 与 TLS candidate 也能正常收集。这只能证明该次会话的连接路径可用,不是网络质量基准,也不能替代桌面 Safari 等组合的回归矩阵。
诊断时还要区分“某条 candidate 失败”和“整条 RTC 连接失败”。浏览器日志里的 icecandidateerror,以及 coturn 拒绝把私网地址作为 relay peer 的记录,都可能与最终连接成功同时出现。后者是阻止 TURN 被用来探测内网的安全边界;是否故障应以最终 nominated candidate pair、连接状态和媒体统计共同判断。移动浏览器如果阻止远端音频自动播放,界面会保留一次明确的用户点击来解锁声音。
部署结构:Caddy + systemd
前端构建成静态文件,由 Caddy 提供 HTTPS。/api/* 和 /ws 反向代理到回环地址上的 Node 服务。Node 使用单独的 systemd unit 运行,不依赖登录 shell 里的 NVM 环境。
站点入口使用 Caddy Basic Auth,房间会话则通过 X-Room-Session 传递。两者不能共用 Authorization:浏览器和反向代理会把它解释成入口认证,曾导致 API 请求重复弹出密码框。分开请求头后,入口密码和房间权限各自只负责一层边界。
TURN 单独使用 turn.whiteboard.example.invalid。站点证书由 Caddy 管理,TURN 证书由 Certbot 获取,因为 coturn 需要读取权限受控的 PEM 文件。Certbot 只在成功续期后运行 deploy hook,把证书更新为 coturn 可读的副本并重启服务;这个链路已经通过 dry-run 验证。Certbot 续期 hook 文档
公网开放 80/443、TURN 的 3478 TCP/UDP、5349 TCP/TLS,以及固定的 49160–49259 UDP relay 范围;Node 的 8787 只监听回环地址。部署脚本在切换带时间戳的 release 前执行类型检查、测试和依赖审计,激活后检查本地健康端点,并保留 current 软链接用于回滚。
目前还没做完的部分
- 补完整的跨浏览器 RTC 测试矩阵,尤其是桌面 Safari、设备切换和网络切换;
- 给 SQLite 和附件目录做一致的备份、恢复演练;
- 增加 WebSocket、数据库写入和最终 ICE candidate pair 的结构化诊断信息;
- 对聊天未读、昵称切换、设备失效回退和画布缩放组合做持续回归;
- 真正面向外部用户时,再把当前针对单机环境的部署说明整理成去环境化的安装文档。
目前没有急着拆服务。真遇到瓶颈时,大致会沿下面几个方向替换:
- 需要多实例写入时,把 SQLite 换成 PostgreSQL;
- 附件备份和迁移变麻烦时,搬到 S3 兼容对象存储;
- WebSocket 连接需要跨实例时,再拆 Realtime Gateway 和事件层;
- 6 人 mesh 不够用时,再考虑 SFU;
- 真正面向团队开放时,再做账户、角色和审计。
这些事情现在都不是前置条件。当前私有部署能稳定覆盖实际使用规模,就先保持单机方案。
最后
这套 MVP 没有什么神奇的地方:Canvas2D 负责画,WebSocket 负责同步,SQLite 负责保存,WebRTC 和 coturn 负责通话,Caddy 和 systemd 负责把它跑起来。
对现在的使用范围来说,这样已经够了。PC Chrome RTC、主要画布交互和生产部署链路已经跑通;接下来优先补桌面浏览器矩阵、备份恢复演练和可观测性。等真的遇到容量问题,再决定哪些组件值得替换。