实时协作看起来毫不费力,直到你揭开其背后的帷幕。有人在打字,有人删除了三个段落前的一行,还有人在粘贴一段来自 Stack Overflow 的代码片段。无论如何,文档最终都会稳定在一个单一且连贯的状态。在没有 WebSockets 或分布式状态经验的情况下,从零开始构建这种流畅性,听起来很鲁莽。但这也听起来像是真正学习的正确方式。
这个项目从零开始。没有借用的样板代码。没有那些在三十秒蒙太奇剪辑中跳过难点的精美 YouTube 教程。目标是构建一个协作式代码编辑器,多个用户可以同时编辑同一个文件,并实时看到彼此的更改——以及彼此的光标。实现这一目标需要搞清楚传输层、一致性模型,以及如何在不损坏文档的情况下合并并发编辑这一棘手问题。
“实时”究竟意味着什么
大多数 Web 应用程序都习惯于请求-响应循环。你提交表单,服务器保存,然后你刷新页面。实时协作则完全打破了这种契约。每一次按键都是一个事件,必须传播到所有其他已连接的客户端,通常在毫秒内完成,并且必须以保持语义的顺序到达。
WebSockets 是这里显而易见的传输选择,因为它们在客户端和服务器之间维持着持久的全双工连接。与每隔几秒就询问“有新东西吗?”从而浪费带宽的 HTTP 轮询不同,WebSocket 会保持开启状态。当用户 A 输入一个分号时,该字符会变成一条消息,通过 socket 传输到中央服务器,然后分发给用户 B 和 C。这部分相对简单。
难点在于当 B 和 C 在同一时刻输入时会发生什么。如果两个更改几乎同时到达服务器,哪一个会生效?如果你只是按到达顺序广播消息,就会面临字符丢失或文本混乱的风险。朴素的“最后写入者胜”策略会失效,因为它们忽略了意图。如果我在第一行开头输入“hello”,而你在第一行开头输入“world”,结果不应该是其中一人被抹除的冲突,而应该是确定性地选择为“helloworld”或“worldhello”。实现这一点需要一种能够理解文档结构的同步策略。
为什么从零开始至关重要
有许多优秀的框架可以隐藏这些复杂性。Yjs、Automerge 和 Socket.IO 可以屏蔽掉这些痛苦,并在一个下午内做出一个可运行的原型。但在不了解底层原语的情况下使用它们,就像在不知道如何读取仪表盘的情况下开启自动驾驶飞行飞机。当湍流来袭时——在分布式系统中,它总是会来袭——你需要知道问题出在网络层、冲突解决还是数据模型上。
这里的承诺是在依赖库之前先学习这些概念。这意味着要手动推导以下情况发生时会发生什么:
- 客户端在按键中途断开连接,并在十秒后重新连接
- 两个用户同时在同一个光标位置插入文本
- 一个用户删除了一个另一个用户正在积极编辑的代码块
- 服务器崩溃,新节点必须从零开始重构文档状态
Operational Transformation (OT) 和 Conflict-free Replicated Data Types (CRDTs) 是解决这些问题的两大主流解决方案体系。Google Docs 著名的早期架构是建立在 OT 之上的,它需要一个中央服务器在应用操作之前,对操作进行相互转换。相比之下,CRDTs 的设计使得并发更新可以在没有协调的情况下在本地进行合并,这使得它们在点对点或基于边缘的设置中非常具有吸引力。在两者之间进行选择(或采用混合方法)需要理解它们在内存使用、收敛保证和实现复杂度方面的权衡。仅仅阅读这些权衡是不够的;计划是实现朴素版和精细版,以观察它们在何处失效。
重构、错误与死胡同
保持诚实的预期。在开发过程中,总会有那么一段时间什么都行不通。第一次尝试可能会使用简单的 JSON patches 来表示文本更改,结果却发现 JSON 根本没有“段落中索引为 5”的概念,因此两个在同一索引处的并发插入会互相覆盖,而不是合并。第二次尝试可能会构建一个自定义的线性历史日志,但随后会意识到,当文档增长时,重放该日志将变成一场大 O 复杂度噩梦。第三次尝试可能会在本地实现 WebSockets,但在真实的网络环境下,丢包和波动的延迟会改写所有的规则。
这种摩擦正是重点所在。直接复制一个现成的仓库会让你跳过对“为什么队列会以特定顺序刷新”或“为什么服务器要维护版本向量”的研究。重建同一个组件三次虽然很慢,但它会迫使你理解框架所做的工作与你自己的逻辑必须处理的部分之间的边界。
这个过程的文档记录不会是精华集锦,它会包含所有的弯路。例如,构建在线状态感知(presence awareness)——即知道谁在线以及他们的光标在哪里——看起来像是一个装饰性功能,直到你意识到它依赖于与文本本身相同的一致性模型。如果用户 A 看到用户 B 的光标在第 10 列,然后用户 B 插入了四个字符,那么光标应该移动到哪里?如果对文档拓扑结构缺乏共同的理解,在线状态数据就会脱离现实。解决这个问题需要将光标位置与底层数据结构的标识(identity)耦合,而不仅仅是它的数值索引。这些细节正是教程往往会略过的地方,不是因为它们不重要,而是因为它们太琐碎了。
下一步计划
目前的路线图设计得非常精简。首批里程碑将包括:
- 一个回显字符事件的原始 WebSocket 服务器,以便亲身体验延迟和连接生命周期
- 客户端上的一个简单字符串缓冲区,以理解为什么在并发情况下简单的插入顺序会失效
- 一个从零开始的用于有序序列的 CRDT,无论它多么低效,都要观察交换律(commutative property)的实际运作
- 逐步集成到实际的代码编辑器界面(很可能是 CodeMirror 或 Monaco 之类的工具),以应对编辑器的命令式 API 与操作历史的函数式本质之间的不匹配
每一步都会附带书面的原理说明。为什么采用这种方法而不是另一种?哪些假设被证明是错误的?哪种抽象发生了泄漏?
真正的收获
在没有 WebSocket 或 CRDT 经验的情况下开始这样一个项目确实令人望而生畏,但专业知识往往只是贴上了更好标签的重复困惑。目标不是快速完成。目标是构建一个行为可预测的系统,因为它的每一层都是带着意图构建的,而不是带着希望导入的。
如果你以前构建过协作软件——无论是文本编辑器、设计工具还是游戏状态同步引擎——请分享那些让你措手不及的失败模式。如果你也在学习这些系统,请跟随我的步伐。代码会缓慢地呈现,并且会被频繁地重写。第 0 天,现在开始。
